basic tempate of web
@@ -0,0 +1,4 @@
|
|||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
||||||
|
not ie 11
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// https://github.com/typescript-eslint/typescript-eslint/issues/251
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:vue/vue3-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
],
|
||||||
|
overrides: [],
|
||||||
|
parser: "vue-eslint-parser",
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
extraFileExtensions: [".vue"],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: ["./tsconfig.node.json", "./tsconfig.json", "./tests/tsconfig.json"],
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
plugins: ["vue", "@typescript-eslint", "prettier"],
|
||||||
|
rules: {
|
||||||
|
// Override/add rules' settings here
|
||||||
|
"vue/valid-v-slot": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
allowModifiers: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$schema: "https://json.schemastore.org/prettierrc"
|
||||||
|
embeddedLanguageFormatting: "auto"
|
||||||
|
trailingComma: "es5"
|
||||||
|
tabWidth: 2
|
||||||
|
semi: false
|
||||||
|
singleQuote: false
|
||||||
|
singleAttributePerLine: true
|
||||||
|
useTabs: false
|
||||||
|
printWidth: 100
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||||
|
|
||||||
|
## Type Support For `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||||
|
|
||||||
|
1. Disable the built-in TypeScript Extension
|
||||||
|
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||||
|
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||||
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:24.15.0-alpine3.23
|
||||||
|
|
||||||
|
RUN npm i -g npm@11.14.1
|
||||||
|
|
||||||
|
WORKDIR /usr/src/web
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm clean-install
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start", "--", "--host", "0.0.0.0"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.googleapis.com"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Audiowide&family=Overpass:ital,wght@0,100..900;1,100..900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/ico"
|
||||||
|
href="/favicon.ico"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
|
<title>ALPHANE - Maintenance</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="/src/main.ts"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "alphane-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"lint": "eslint . --ext .js,.ts,.vue --ignore-path ../.gitignore",
|
||||||
|
"check-types": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth0/auth0-vue": "^2.5.0",
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"@tabler/icons-vue": "^3.36.0",
|
||||||
|
"@vueuse/core": "^13.9.0",
|
||||||
|
"@vueuse/router": "^13.9.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
|
"grid-layout-plus": "^1.1.1",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
|
"marked": "^17.0.5",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"validator": "^13.15.26",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-draggable-next": "^2.3.0",
|
||||||
|
"vue-i18n": "^11.3.0",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
|
"vue-scrollto": "^2.20.0",
|
||||||
|
"vuetify": "^3.11.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/lodash": "^4.17.21",
|
||||||
|
"@types/luxon": "^3.7.1",
|
||||||
|
"@types/md5": "^2.3.6",
|
||||||
|
"@types/qs": "^6.14.0",
|
||||||
|
"@types/validator": "^13.15.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||||
|
"@typescript-eslint/parser": "^8.50.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prettier-plugin-embed": "^0.5.1",
|
||||||
|
"prettier-plugin-sql": "^0.19.2",
|
||||||
|
"sass": "^1.97.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.0",
|
||||||
|
"vite-plugin-vuetify": "^2.1.2",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"vue-tsc": "^3.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="109" height="44" viewBox="0 0 109 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M99 26.9C101.3 26.9 102.6 28.4 102.6 31.2V42.7H108.4V29.3C108.4 24.5 105.8 21.6 101.4 21.6C98.3 21.6 96.6 23.3 95.4 24.9L95.3 25V22H89.5V42.7H95.3V31.1C95.2 28.4 96.6 26.9 99 26.9Z" fill="black"/>
|
||||||
|
<path d="M81.6 32.5C81.6 34 81.1 35.5 80.2 36.5C79.2 37.6 77.8 38.2 76.2 38.2C74.6 38.2 73.2 37.6 72.2 36.5C71.2 35.4 70.7 34 70.7 32.4V32.3C70.7 30.8 71.2 29.3 72.1 28.3C73.1 27.2 74.5 26.6 76.1 26.6C77.7 26.6 79.1 27.2 80.1 28.3C81 29.4 81.6 30.9 81.6 32.5ZM76.1 21.6C73 21.6 70.2 22.7 68.1 24.8C66 26.8 64.9 29.5 64.9 32.4V32.5C64.9 35.4 66 38.1 68.1 40.1C70.2 42.1 73 43.2 76.1 43.2C79.2 43.2 82 42.1 84.1 40C86.2 38 87.3 35.3 87.3 32.4V32.3C87.3 29.4 86.2 26.7 84.1 24.7C82.1 22.7 79.2 21.6 76.1 21.6Z" fill="black"/>
|
||||||
|
<path d="M59.8 42.7H66.4L58.3 30.1L66.1 22H59.2L52.3 29.7V15.1H46.5V42.7H52.3V36.4L54.4 34.2L59.8 42.7Z" fill="black"/>
|
||||||
|
<path d="M31.6 43.1C34.7 43.1 36.4 41.4 37.6 39.8L37.7 39.7V42.7H43.5V22H37.7V33.5C37.7 36.2 36.3 37.8 34 37.8C31.7 37.8 30.4 36.3 30.4 33.5V22H24.6V35.4C24.6 40.2 27.2 43.1 31.6 43.1Z" fill="black"/>
|
||||||
|
<path d="M20.7 15.7L14 26.3V26.2L7.5 15.7H0.699997L11.1 31.7V42.7H16.9V31.6L27.3 15.7H20.7Z" fill="black"/>
|
||||||
|
<path d="M76.1 0.800003L74 7.4L69.5 2.1L70.2 9L64 5.9L67.2 12L60.3 11.4L65.6 15.9L59 18L64.7 19.8L69.6 14.5L71.6 16.7L76.2 11.8L80.7 16.7L82.7 14.5L87.6 19.7L93.3 17.9L86.7 15.8L92 11.3L85.1 12L88.2 5.8L82.1 9L82.7 2.1L78.2 7.4L76.1 0.800003Z" fill="#F2A900"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<router-view v-if="isUnauthenticatedRoute"></router-view>
|
||||||
|
<!--
|
||||||
|
NOTE: current user will always be defined when the authenticated router view loads.
|
||||||
|
-->
|
||||||
|
<template v-else-if="isReady || isErrored">
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
<PageLoader
|
||||||
|
v-else-if="isLoadingAuth0 || isLoadingCurrentUser"
|
||||||
|
message="ALPHANE"
|
||||||
|
/>
|
||||||
|
<PageLoader
|
||||||
|
v-else-if="!isReady"
|
||||||
|
message="Loading"
|
||||||
|
/>
|
||||||
|
<PageLoader
|
||||||
|
v-else
|
||||||
|
message="Initializing"
|
||||||
|
/>
|
||||||
|
<AppSnackbar />
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onErrorCaptured, onMounted, ref, watch } from "vue"
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import ApiError from "@/api/api-error"
|
||||||
|
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useInterface from "@/use/use-interface"
|
||||||
|
|
||||||
|
import PageLoader from "@/components/common/PageLoader.vue"
|
||||||
|
import AppSnackbar from "@/components/common/AppSnackbar.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const isUnauthenticatedRoute = computed(() => route.meta.requiresAuth === false)
|
||||||
|
|
||||||
|
const { isLoading: isLoadingAuth0, isAuthenticated } = useAuth0()
|
||||||
|
const isReadyAuth0 = computed(() => !isLoadingAuth0.value && isAuthenticated.value)
|
||||||
|
|
||||||
|
const { isReady: isReadyCurrentUser, isLoading: isLoadingCurrentUser, fetch } = useCurrentUser()
|
||||||
|
|
||||||
|
const isReady = computed(() => isReadyAuth0.value && isReadyCurrentUser.value)
|
||||||
|
|
||||||
|
const isErrored = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { mdAndDown } = useDisplay()
|
||||||
|
const { setSidebarDrawer, setSidebarMini } = useInterface()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (mdAndDown.value === false) {
|
||||||
|
setSidebarDrawer(true)
|
||||||
|
setSidebarMini(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => isReadyAuth0.value, isUnauthenticatedRoute],
|
||||||
|
async ([newIsReadyAuth0, newIsUnauthenticatedRoute]) => {
|
||||||
|
// Don't bother attempting to load current user for unauthenticated routes
|
||||||
|
if (newIsUnauthenticatedRoute) return
|
||||||
|
|
||||||
|
if (newIsReadyAuth0 === true && !isReadyCurrentUser.value) {
|
||||||
|
try {
|
||||||
|
await fetch()
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Failed to load current user:", error)
|
||||||
|
isErrored.value = true
|
||||||
|
await router.isReady()
|
||||||
|
await router.push({ name: "SignInPage" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onErrorCaptured((error: Error) => {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
redirectToAppropriateErrorPage(error.status)
|
||||||
|
} else {
|
||||||
|
console.error(`Unhandled error: ${error}`, { error })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function redirectToAppropriateErrorPage(httpErrorCode: number) {
|
||||||
|
if (httpErrorCode === 400 || httpErrorCode === 422) return
|
||||||
|
|
||||||
|
switch (httpErrorCode) {
|
||||||
|
case 401:
|
||||||
|
return router.replace({
|
||||||
|
name: "errors/UnauthorizedPage",
|
||||||
|
})
|
||||||
|
case 403:
|
||||||
|
return router.replace({
|
||||||
|
name: "errors/ForbiddenPage",
|
||||||
|
})
|
||||||
|
case 404:
|
||||||
|
return router.replace({
|
||||||
|
name: "errors/NotFoundPage",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return router.replace({
|
||||||
|
name: "errors/InternalServerErrorPage",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
public readonly name = "ApiError"
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiError
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/** Keep in sync with api/src/controllers/base-controller.ts#ModelOrder */
|
||||||
|
export type ModelOrder =
|
||||||
|
| [string, string]
|
||||||
|
| [string, string, string]
|
||||||
|
| [string, string, string, string]
|
||||||
|
| [string, string, string, string, string]
|
||||||
|
| [string, string, string, string, string, string]
|
||||||
|
|
||||||
|
export type Policy = {
|
||||||
|
show: boolean
|
||||||
|
create: boolean
|
||||||
|
update: boolean
|
||||||
|
destroy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WhereOptions<Model, Attributes extends keyof Model> = {
|
||||||
|
[K in Attributes]?: Model[K] | Model[K][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FiltersOptions<Options> = Partial<Options>
|
||||||
|
|
||||||
|
export type QueryOptions<WhereOptions, FiltersOptions> = Partial<{
|
||||||
|
where: WhereOptions
|
||||||
|
filters: FiltersOptions
|
||||||
|
order: ModelOrder[]
|
||||||
|
page: number
|
||||||
|
perPage: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
// Keep in sync with api/src/controllers/base-controller.ts
|
||||||
|
export const MAX_PER_PAGE = 1000
|
||||||
|
|
||||||
|
export type ApiResponseError = { field?: string; text: string }
|
||||||
|
|
||||||
|
export type ApiResponseLegacy<T> = {
|
||||||
|
data: T
|
||||||
|
errors: ApiResponseError[]
|
||||||
|
messages?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResponse<TPayload = object> = {
|
||||||
|
errors: ApiResponseError[]
|
||||||
|
messages?: string[]
|
||||||
|
} & TPayload
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import http from "@/api/http-client"
|
||||||
|
|
||||||
|
import { type Policy } from "@/api/base-api"
|
||||||
|
import { UserRoles, type User } from "@/api/users-api"
|
||||||
|
|
||||||
|
export { UserRoles }
|
||||||
|
|
||||||
|
export type UserAsShow = Pick<
|
||||||
|
User,
|
||||||
|
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
||||||
|
>
|
||||||
|
|
||||||
|
export const currentUserApi = {
|
||||||
|
async get(): Promise<{
|
||||||
|
user: UserAsShow
|
||||||
|
policy: Policy
|
||||||
|
}> {
|
||||||
|
const { data } = await http.get(`/api/current-user`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default currentUserApi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import qs from "qs"
|
||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/config"
|
||||||
|
import auth0 from "@/plugins/auth0-plugin"
|
||||||
|
import ApiError from "@/api/api-error"
|
||||||
|
|
||||||
|
export const httpClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
paramsSerializer: {
|
||||||
|
serialize: (params) => {
|
||||||
|
return qs.stringify(params, {
|
||||||
|
arrayFormat: "indices",
|
||||||
|
strictNullHandling: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
httpClient.interceptors.request.use(async (config) => {
|
||||||
|
// Only add the Authorization header to requests that start with "/api"
|
||||||
|
if (config.url?.startsWith("/api")) {
|
||||||
|
const accessToken = await auth0.getAccessTokenSilently()
|
||||||
|
config.headers["Authorization"] = `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Any status codes that falls outside the range of 2xx causes this function to trigger
|
||||||
|
httpClient.interceptors.response.use(null, async (error) => {
|
||||||
|
if (error?.error === "login_required") {
|
||||||
|
throw new ApiError("You must be logged in to access this endpoint", 401)
|
||||||
|
} else if (error?.response?.data?.message) {
|
||||||
|
throw new ApiError(error.response.data.message, error.response.status)
|
||||||
|
} else if (error.message) {
|
||||||
|
throw new ApiError(error.message, error.response?.status || 500)
|
||||||
|
} else {
|
||||||
|
throw new ApiError("An unknown error occurred", error.response?.status || 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default httpClient
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import http from "@/api/http-client"
|
||||||
|
|
||||||
|
export type Status = {
|
||||||
|
RELEASE_TAG: string
|
||||||
|
GIT_COMMIT_HASH: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statusApi = {
|
||||||
|
/**
|
||||||
|
* Note: This is a public API route, and not protected by authentication
|
||||||
|
*/
|
||||||
|
async get(): Promise<Status> {
|
||||||
|
const { data } = await http.get("/_status")
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default statusApi
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import http from "@/api/http-client"
|
||||||
|
import {
|
||||||
|
type FiltersOptions,
|
||||||
|
type ModelOrder,
|
||||||
|
type Policy,
|
||||||
|
type WhereOptions,
|
||||||
|
} from "@/api/base-api"
|
||||||
|
|
||||||
|
/** Keep in sync with api/src/models/user.ts */
|
||||||
|
export enum UserRoles {
|
||||||
|
SYSTEM_ADMIN = "system_admin",
|
||||||
|
USER = "user",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
displayName: string
|
||||||
|
roles: UserRoles[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserAsShow = Omit<User, "organizations"> & {}
|
||||||
|
|
||||||
|
export type UserWhereOptions = WhereOptions<User, "email" | "firstName" | "lastName">
|
||||||
|
|
||||||
|
export type UserFiltersOptions = FiltersOptions<{
|
||||||
|
search: string | string[]
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type UserQueryOptions = {
|
||||||
|
where?: UserWhereOptions
|
||||||
|
filters?: UserFiltersOptions
|
||||||
|
order?: ModelOrder[]
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
UserRoles,
|
||||||
|
async list(params: UserQueryOptions = {}): Promise<{
|
||||||
|
users: User[]
|
||||||
|
totalCount: number
|
||||||
|
}> {
|
||||||
|
const { data } = await http.get("/api/users", {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async get(userId: number): Promise<{
|
||||||
|
user: User
|
||||||
|
policy: Policy
|
||||||
|
}> {
|
||||||
|
const { data } = await http.get(`/api/users/${userId}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async create(attributes: Partial<User>): Promise<{
|
||||||
|
user: User
|
||||||
|
}> {
|
||||||
|
const { data } = await http.post("/api/users", attributes)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async update(
|
||||||
|
userId: number,
|
||||||
|
attributes: Partial<User>
|
||||||
|
): Promise<{
|
||||||
|
user: User
|
||||||
|
}> {
|
||||||
|
const { data } = await http.patch(`/api/users/${userId}`, attributes)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async delete(userId: number): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/users/${userId}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usersApi
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,55 @@
|
|||||||
|
<!-- Special module scope required for exports -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { RouteLocationRaw } from "vue-router"
|
||||||
|
|
||||||
|
type Breadcrumb = {
|
||||||
|
title: string
|
||||||
|
to: RouteLocationRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type Breadcrumb }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
breadcrumbs: Breadcrumb[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-3 mb-6">
|
||||||
|
<div class="d-flex justify-space-between">
|
||||||
|
<div class="d-flex py-0 align-center">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-h3 mb-2">{{ props.title }}</h2>
|
||||||
|
<v-breadcrumbs
|
||||||
|
:items="props.breadcrumbs"
|
||||||
|
class="text-h6 font-weight-regular pa-0 ml-n1"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-if="props.breadcrumbs"
|
||||||
|
#divider
|
||||||
|
>
|
||||||
|
<v-icon>mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
<template #title="{ item }">
|
||||||
|
<h6 class="text-medium-emphasis text-subtitle-1">{{ item.title }}</h6>
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<slot name="append" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.page-breadcrumb {
|
||||||
|
.v-toolbar {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<v-card
|
||||||
|
class="mb-5 app-card"
|
||||||
|
:to="to"
|
||||||
|
>
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="mainColor && !isNil(progressValue)"
|
||||||
|
:model-value="progressValue"
|
||||||
|
:color="mainColor"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<span style="font-size: 12px"> {{ progressValue }}% Complete</span>
|
||||||
|
</v-progress-linear>
|
||||||
|
<v-card-title
|
||||||
|
v-if="title"
|
||||||
|
class="d-flex"
|
||||||
|
:class="{ 'mb-n3': subtitle }"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
<v-spacer />
|
||||||
|
<slot name="append-title"></slot>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-subtitle
|
||||||
|
v-if="subtitle"
|
||||||
|
:class="{ 'mt-3': !title }"
|
||||||
|
style="font-weight: 500 !important"
|
||||||
|
>
|
||||||
|
{{ subtitle }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
<v-card-text :class="{ 'mt-n5': subtitle }">
|
||||||
|
<slot></slot>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
to?: string | { name: string; params?: Record<string, string | number> }
|
||||||
|
mainColor?: string
|
||||||
|
progressValue?: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logo">
|
||||||
|
<RouterLink
|
||||||
|
to="/dashboard"
|
||||||
|
class="d-flex"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="ml-0 mt-1"
|
||||||
|
style="height: 36px; transform: rotate(-12deg)"
|
||||||
|
:src="AppLogo"
|
||||||
|
/>
|
||||||
|
<div v-if="sidebarMini || mdAndDown"></div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="d-flex"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mt-1 ml-3 rotyr-font"
|
||||||
|
style="font-size: 26px; color: #505682"
|
||||||
|
>
|
||||||
|
ALPHANE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AppLogo from "@/assets/app_logo_small.png"
|
||||||
|
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
import useInterface from "@/use/use-interface"
|
||||||
|
import { watch } from "vue"
|
||||||
|
|
||||||
|
const { sidebarMini, setSidebarMini } = useInterface()
|
||||||
|
const { mdAndDown } = useDisplay()
|
||||||
|
|
||||||
|
watch(mdAndDown, (newVal) => {
|
||||||
|
if (newVal === true) setSidebarMini(false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo a {
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<v-snackbar
|
||||||
|
v-model="showSnackbar"
|
||||||
|
v-bind="{
|
||||||
|
multiLine: true,
|
||||||
|
timeout: defaultTimeout,
|
||||||
|
...options,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span :class="`text-${constrastingColor}`">
|
||||||
|
{{ message }}
|
||||||
|
</span>
|
||||||
|
<template #actions>
|
||||||
|
<v-btn
|
||||||
|
:color="constrastingColor"
|
||||||
|
variant="text"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
|
||||||
|
import { useSnack } from "@/use/use-snack"
|
||||||
|
import { isEmpty } from "lodash"
|
||||||
|
|
||||||
|
const { message, options, reset } = useSnack()
|
||||||
|
|
||||||
|
const showSnackbar = ref(false)
|
||||||
|
const defaultTimeout = 4000
|
||||||
|
|
||||||
|
const constrastingColor = computed(() => getContrastingColor(options.value.color))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [message.value, options.value],
|
||||||
|
() => {
|
||||||
|
if (isEmpty(message.value)) return
|
||||||
|
|
||||||
|
show()
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => showSnackbar.value,
|
||||||
|
(newShowSnackbar) => {
|
||||||
|
if (newShowSnackbar === false) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
showSnackbar.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
showSnackbar.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContrastingColor(color: string | undefined) {
|
||||||
|
if (color === undefined) return "white"
|
||||||
|
|
||||||
|
const colorMap: {
|
||||||
|
[key: string]: "white" | "black" | undefined
|
||||||
|
} = {
|
||||||
|
primary: "white",
|
||||||
|
secondary: "black",
|
||||||
|
accent: "black",
|
||||||
|
error: "white",
|
||||||
|
info: "white",
|
||||||
|
success: "white",
|
||||||
|
warning: "black",
|
||||||
|
}
|
||||||
|
|
||||||
|
return colorMap[color] || "white"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useSlots } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
|
function toggleCollapse() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card class="mb-5 savable-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex">
|
||||||
|
<div
|
||||||
|
class="d-flex cursor-pointer"
|
||||||
|
@click="toggleCollapse"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
:icon="collapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'"
|
||||||
|
color="primary"
|
||||||
|
class="mr-4 mt-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 class="text-h5 title mb-1">{{ props.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<v-spacer v-if="slots.rightpart && !collapsed" />
|
||||||
|
<div
|
||||||
|
v-if="slots.rightpart && !collapsed"
|
||||||
|
class="float-right"
|
||||||
|
>
|
||||||
|
<!---Toggle Button For mobile-->
|
||||||
|
<slot name="rightpart"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-slide-y-transition>
|
||||||
|
<div v-show="!collapsed">
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</v-slide-y-transition>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="internalDialog"
|
||||||
|
max-width="500"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h6">
|
||||||
|
{{ title }}
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<slot>{{ message }}</slot>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
{{ cancelText }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
:color="confirmColor"
|
||||||
|
variant="flat"
|
||||||
|
@click="confirm"
|
||||||
|
>
|
||||||
|
{{ confirmText }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
confirmColor?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: "Confirm",
|
||||||
|
message: "Are you sure?",
|
||||||
|
confirmText: "Confirm",
|
||||||
|
cancelText: "Cancel",
|
||||||
|
confirmColor: "primary",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: boolean]
|
||||||
|
confirm: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const internalDialog = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit("update:modelValue", value),
|
||||||
|
})
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
emit("confirm")
|
||||||
|
internalDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit("cancel")
|
||||||
|
internalDialog.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Vertical layout -->
|
||||||
|
<div v-if="vertical">
|
||||||
|
<dt class="d-flex align-center font-weight-medium">
|
||||||
|
<v-icon
|
||||||
|
v-if="icon"
|
||||||
|
size="18"
|
||||||
|
class="mr-2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ icon }}
|
||||||
|
</v-icon>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-2">
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="loading"
|
||||||
|
indeterminate
|
||||||
|
size="16"
|
||||||
|
width="1"
|
||||||
|
/>
|
||||||
|
<slot>{{ modelValue }}</slot>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horizontal layout -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="d-flex align-center gap-2"
|
||||||
|
>
|
||||||
|
<dt class="d-flex align-center font-weight-medium">
|
||||||
|
<v-icon
|
||||||
|
v-if="icon"
|
||||||
|
size="18"
|
||||||
|
class="mr-2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ icon }}
|
||||||
|
</v-icon>
|
||||||
|
<span>{{ label }}:</span>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="loading"
|
||||||
|
indeterminate
|
||||||
|
size="16"
|
||||||
|
width="1"
|
||||||
|
/>
|
||||||
|
<slot>{{ modelValue }}</slot>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
modelValue?: string | number | boolean | null | undefined
|
||||||
|
vertical?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
icon: "",
|
||||||
|
modelValue: null,
|
||||||
|
vertical: false,
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gap-2 {
|
||||||
|
gap: 0.5rem; /* 8px */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<v-card :elevation="elevation">
|
||||||
|
<HeaderActionsCardBody
|
||||||
|
:title="title"
|
||||||
|
:header-tag="headerTag"
|
||||||
|
:header-class="headerClass"
|
||||||
|
:divider-class="dividerClass"
|
||||||
|
:hide-body="hideBody"
|
||||||
|
:body-class="bodyClass"
|
||||||
|
:header-icon="headerIcon"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<slot name="header"></slot>
|
||||||
|
</template>
|
||||||
|
<template #header-actions>
|
||||||
|
<slot name="header-actions"></slot>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<slot></slot>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-if="$slots.actions"
|
||||||
|
#actions
|
||||||
|
>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</template>
|
||||||
|
<!--
|
||||||
|
TODO: replace current #actions slot with #actions-next code.
|
||||||
|
-->
|
||||||
|
<template
|
||||||
|
v-if="$slots['actions-next']"
|
||||||
|
#actions-next
|
||||||
|
>
|
||||||
|
<slot name="actions-next"></slot>
|
||||||
|
</template>
|
||||||
|
</HeaderActionsCardBody>
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type VCard } from "vuetify/components"
|
||||||
|
|
||||||
|
import { type VueHtmlClass } from "@/utils/utility-types"
|
||||||
|
|
||||||
|
import HeaderActionsCardBody from "@/components/common/HeaderActionsCardBody.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep in sync with web/src/components/common/HeaderActionsCardBody.vue
|
||||||
|
*/
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"
|
||||||
|
headerClass?: VueHtmlClass
|
||||||
|
dividerClass?: VueHtmlClass
|
||||||
|
elevation?: VCard["elevation"]
|
||||||
|
hideBody?: boolean
|
||||||
|
bodyClass?: VueHtmlClass
|
||||||
|
headerIcon?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
headerTag: "h3",
|
||||||
|
headerClass: "text-h5",
|
||||||
|
dividerClass: "mb-3",
|
||||||
|
elevation: 0,
|
||||||
|
hideBody: false,
|
||||||
|
bodyClass: "",
|
||||||
|
headerIcon: "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<v-card-title class="d-flex flex-sm-row justify-sm-space-between align-sm-end">
|
||||||
|
<slot name="header">
|
||||||
|
<component
|
||||||
|
:is="headerTag"
|
||||||
|
v-if="!isEmpty(title)"
|
||||||
|
:class="headerClass"
|
||||||
|
class="py-2"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
v-if="headerIcon"
|
||||||
|
class="mr-2"
|
||||||
|
>{{ headerIcon }}</v-icon
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</component>
|
||||||
|
</slot>
|
||||||
|
<v-spacer class="mt-4 mt-md-0" />
|
||||||
|
<slot name="header-actions"></slot>
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider
|
||||||
|
v-if="!isEmpty(title)"
|
||||||
|
:class="dividerClass"
|
||||||
|
/>
|
||||||
|
<v-card-text
|
||||||
|
v-if="!hideBody"
|
||||||
|
:class="bodyClass"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="$slots.actions"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<!--
|
||||||
|
TODO: replace current #actions slot with #actions-next code.
|
||||||
|
-->
|
||||||
|
<v-card-actions
|
||||||
|
v-if="$slots['actions-next']"
|
||||||
|
class="d-flex flex-column flex-md-row"
|
||||||
|
>
|
||||||
|
<slot name="actions-next"></slot>
|
||||||
|
</v-card-actions>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { isEmpty } from "lodash"
|
||||||
|
|
||||||
|
import { type VueHtmlClass } from "@/utils/utility-types"
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"
|
||||||
|
headerClass?: VueHtmlClass
|
||||||
|
dividerClass?: VueHtmlClass
|
||||||
|
hideBody?: boolean
|
||||||
|
bodyClass?: VueHtmlClass
|
||||||
|
headerIcon?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
headerTag: "h3",
|
||||||
|
headerClass: "text-h5",
|
||||||
|
dividerClass: "mb-3",
|
||||||
|
hideBody: false,
|
||||||
|
bodyClass: "",
|
||||||
|
headerIcon: "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<v-card :elevation="elevation">
|
||||||
|
<v-form
|
||||||
|
ref="form"
|
||||||
|
v-model="isValid"
|
||||||
|
@submit="emit('submit', $event)"
|
||||||
|
>
|
||||||
|
<HeaderActionsCardBody
|
||||||
|
:title="title"
|
||||||
|
:header-tag="headerTag"
|
||||||
|
:header-class="headerClass"
|
||||||
|
:divider-class="dividerClass"
|
||||||
|
:body-class="bodyClass"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<slot name="header"></slot>
|
||||||
|
</template>
|
||||||
|
<template #header-actions>
|
||||||
|
<slot name="header-actions"></slot>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<slot></slot>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-if="$slots.actions"
|
||||||
|
#actions
|
||||||
|
>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</template>
|
||||||
|
<!--
|
||||||
|
TODO: replace current #actions slot with #actions-next code.
|
||||||
|
-->
|
||||||
|
<template
|
||||||
|
v-if="$slots['actions-next']"
|
||||||
|
#actions-next
|
||||||
|
>
|
||||||
|
<slot name="actions-next"></slot>
|
||||||
|
</template>
|
||||||
|
</HeaderActionsCardBody>
|
||||||
|
</v-form>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import { type VCard, type VForm } from "vuetify/components"
|
||||||
|
import { type SubmitEventPromise } from "vuetify/lib/composables/form"
|
||||||
|
|
||||||
|
import { type VueHtmlClass } from "@/utils/utility-types"
|
||||||
|
|
||||||
|
import HeaderActionsCardBody from "@/components/common/HeaderActionsCardBody.vue"
|
||||||
|
|
||||||
|
const isValid = defineModel<boolean | null>({ default: null })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep in sync with web/src/components/common/HeaderActionsCardBody.vue
|
||||||
|
*/
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
||||||
|
headerClass?: VueHtmlClass
|
||||||
|
dividerClass?: VueHtmlClass
|
||||||
|
bodyClass?: VueHtmlClass
|
||||||
|
elevation?: VCard["elevation"]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
headerTag: "h3",
|
||||||
|
headerClass: "text-h5",
|
||||||
|
dividerClass: "mb-3",
|
||||||
|
bodyClass: "",
|
||||||
|
elevation: 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "submit", value: SubmitEventPromise): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = ref<InstanceType<typeof VForm> | null>(null)
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
if (isNil(form.value)) throw new Error("form component not loaded")
|
||||||
|
|
||||||
|
return form.value?.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetValidation() {
|
||||||
|
if (isNil(form.value)) throw new Error("form component not loaded")
|
||||||
|
|
||||||
|
return form.value?.resetValidation()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
validate,
|
||||||
|
resetValidation,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex justify-center align-center h-screen">
|
||||||
|
<div class="d-flex flex-column align-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
size="64"
|
||||||
|
class="mb-5"
|
||||||
|
color="#505682"
|
||||||
|
/>
|
||||||
|
<h1 class="text-center">{{ message }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
withDefaults(defineProps<{ message: string }>(), { message: "ALPAHNE" })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<v-text-field
|
||||||
|
:model-value="modelValue"
|
||||||
|
:label="label"
|
||||||
|
:rules="combinedRules"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:clearable="clearable"
|
||||||
|
:hint="hint"
|
||||||
|
:persistent-hint="persistentHint"
|
||||||
|
type="number"
|
||||||
|
suffix="%"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.01"
|
||||||
|
@update:model-value="updateModelValue"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { VTextField } from "vuetify/components"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: number | null | undefined
|
||||||
|
label?: string
|
||||||
|
rules?: VTextField["rules"]
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
clearable?: boolean
|
||||||
|
hint?: string
|
||||||
|
persistentHint?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: "Percentage",
|
||||||
|
rules: () => [],
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
clearable: false,
|
||||||
|
persistentHint: false,
|
||||||
|
hint: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: number | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const combinedRules = computed(() => {
|
||||||
|
const baseRules = [
|
||||||
|
(value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return true
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
(value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return true
|
||||||
|
const numValue = Number(value)
|
||||||
|
if (isNaN(numValue)) return "Must be a valid number"
|
||||||
|
if (numValue < 0) return "Percentage must be at least 0"
|
||||||
|
if (numValue > 100) return "Percentage must be at most 100"
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return [...baseRules, ...(props.rules || [])]
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateModelValue(value: string | number | null) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
emit("update:modelValue", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const numValue = Number(value)
|
||||||
|
if (isNaN(numValue)) {
|
||||||
|
emit("update:modelValue", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp value between 0 and 100
|
||||||
|
const clampedValue = Math.min(Math.max(numValue, 0), 100)
|
||||||
|
emit("update:modelValue", clampedValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="internalDialog"
|
||||||
|
:max-width="mobile ? undefined : maxWidth"
|
||||||
|
:fullscreen="mobile"
|
||||||
|
:persistent="persistent"
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
<HeaderActionsCard>
|
||||||
|
<template #header>
|
||||||
|
<div
|
||||||
|
class="d-flex align-start ga-2 w-100"
|
||||||
|
:class="{ 'pl-4': mobile, 'pt-2': mobile }"
|
||||||
|
style="white-space: normal"
|
||||||
|
>
|
||||||
|
<slot name="title">
|
||||||
|
<h3
|
||||||
|
class="text-h5 py-2 flex-grow-1 text-break"
|
||||||
|
style="min-width: 0; overflow-wrap: anywhere; white-space: normal"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
v-if="titleIcon"
|
||||||
|
class="mr-2"
|
||||||
|
>{{ titleIcon }}</v-icon
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
</slot>
|
||||||
|
<v-btn
|
||||||
|
v-if="!hideClose"
|
||||||
|
icon="mdi-close"
|
||||||
|
:variant="mobile ? 'tonal' : 'text'"
|
||||||
|
density="comfortable"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</HeaderActionsCard>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
|
||||||
|
import HeaderActionsCard from "@/components/shared/cards/HeaderActionsCard.vue"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title?: string
|
||||||
|
titleIcon?: string
|
||||||
|
maxWidth?: number | string
|
||||||
|
persistent?: boolean
|
||||||
|
hideClose?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
titleIcon: "",
|
||||||
|
maxWidth: 700,
|
||||||
|
persistent: false,
|
||||||
|
hideClose: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { mobile } = useDisplay()
|
||||||
|
|
||||||
|
const internalDialog = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit("update:modelValue", value),
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
internalDialog.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<v-date-input
|
||||||
|
v-model="date"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@update:model-value="emitStringResult"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A date input accepts and returns a string.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string | null | undefined
|
||||||
|
returnFormat?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
returnFormat: "yyyy-MM-dd",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const date = ref<Date | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
if (isNil(newValue)) {
|
||||||
|
date.value = null
|
||||||
|
} else {
|
||||||
|
const dateTime = DateTime.fromISO(newValue)
|
||||||
|
if (dateTime.isValid) {
|
||||||
|
date.value = dateTime.toJSDate()
|
||||||
|
} else {
|
||||||
|
date.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: v-date-input returns Date | string, rather than just "string" like it's types imply.
|
||||||
|
*/
|
||||||
|
function emitStringResult(value: unknown) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
const dateTime = DateTime.fromJSDate(value)
|
||||||
|
if (dateTime.isValid) {
|
||||||
|
const stringValue = dateTime.toFormat(props.returnFormat)
|
||||||
|
emit("update:modelValue", stringValue)
|
||||||
|
} else {
|
||||||
|
emit("update:modelValue", null)
|
||||||
|
}
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
emit("update:modelValue", value)
|
||||||
|
} else {
|
||||||
|
emit("update:modelValue", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<v-app-bar
|
||||||
|
id="top"
|
||||||
|
elevation="10"
|
||||||
|
height="60"
|
||||||
|
class="main-head pl-2"
|
||||||
|
>
|
||||||
|
<div class="mr-3">
|
||||||
|
<AppLogo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="isSystemAdmin"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
class="custom-hover-primary mr-2"
|
||||||
|
size="small"
|
||||||
|
title="AI Assistant"
|
||||||
|
@click="$emit('toggle-ai-panel')"
|
||||||
|
>
|
||||||
|
<v-icon size="28">mdi-robot-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<ProfileMenu />
|
||||||
|
</v-app-bar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
import AppLogo from "@/components/common/AppLogo.vue"
|
||||||
|
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"toggle-ai-panel": []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { isSystemAdmin } = useCurrentUser<true>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<v-navigation-drawer
|
||||||
|
v-model="sidebarDrawer"
|
||||||
|
left
|
||||||
|
elevation="0"
|
||||||
|
rail-width="75"
|
||||||
|
app
|
||||||
|
class="leftSidebar"
|
||||||
|
:rail="sidebarMini"
|
||||||
|
expand-on-hover
|
||||||
|
width="256"
|
||||||
|
>
|
||||||
|
<div class="pa-2">
|
||||||
|
<v-card
|
||||||
|
v-if="currentUser"
|
||||||
|
variant="outlined"
|
||||||
|
style="overflow: hidden"
|
||||||
|
>
|
||||||
|
<v-card-text
|
||||||
|
v-if="!sidebarMini"
|
||||||
|
class="pa-2"
|
||||||
|
>
|
||||||
|
<div class="font-weight-bold">{{ currentUser.displayName }}</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-list class="d-none py-3 px-4">
|
||||||
|
<v-list-subheader
|
||||||
|
class="text-uppercase text-13 font-weight-semibold textPrimary d-flex align-items-center"
|
||||||
|
>
|
||||||
|
<span class="mini-icon">
|
||||||
|
<IconDotsCircleHorizontal
|
||||||
|
size="16"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="iconClass"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="mini-text">Aircraft</span>
|
||||||
|
</v-list-subheader>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-scroll-to="{ el: '#top' }"
|
||||||
|
:to="{}"
|
||||||
|
rounded="pill"
|
||||||
|
class="mb-1 px-3"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<IconPointFilled
|
||||||
|
size="24"
|
||||||
|
class="dot mini-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span class="mini-text"> Y-CCAA</span>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { IconDotsCircleHorizontal, IconPointFilled } from "@tabler/icons-vue"
|
||||||
|
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useInterface from "@/use/use-interface"
|
||||||
|
|
||||||
|
const { sidebarDrawer, sidebarMini } = useInterface()
|
||||||
|
|
||||||
|
const { currentUser } = useCurrentUser()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<v-breadcrumbs
|
||||||
|
class="flex-wrap"
|
||||||
|
:items="breadcrumbsWithExactTrueByDefault"
|
||||||
|
color=""
|
||||||
|
>
|
||||||
|
<template #title="{ item }">
|
||||||
|
<transition
|
||||||
|
name="breadcrumb-title-fade"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="isEmpty(item.title)"
|
||||||
|
key="title-loader"
|
||||||
|
size="16"
|
||||||
|
color="secondary"
|
||||||
|
width="1"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
key="title-text"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
<template #divider>
|
||||||
|
<v-icon>mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export { type Breadcrumb } from "@/use/use-breadcrumbs"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { isEmpty } from "lodash"
|
||||||
|
|
||||||
|
import { type Breadcrumb } from "@/use/use-breadcrumbs"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: Breadcrumb[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Changes https://vuetifyjs.com/en/components/breadcrumbs/ default behavior.
|
||||||
|
// By default v-breadcrumbs will disable all crumbs up to the current page in a nested paths.
|
||||||
|
// You can prevent this behavior by using exact: true on each applicable breadcrumb in the items array.
|
||||||
|
const breadcrumbsWithExactTrueByDefault = computed(() =>
|
||||||
|
props.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
title: item.title ?? "",
|
||||||
|
exact: item.exact ?? true,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.breadcrumb-title-fade-enter-active,
|
||||||
|
.breadcrumb-title-fade-leave-active {
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
.breadcrumb-title-fade-enter-from,
|
||||||
|
.breadcrumb-title-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu
|
||||||
|
v-model="showMenu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
class="profile_popup"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
class="ml-2"
|
||||||
|
variant="tonal"
|
||||||
|
v-bind="props"
|
||||||
|
icon
|
||||||
|
:text="currentUserInitials"
|
||||||
|
@click="showMenu = true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-sheet
|
||||||
|
rounded="md"
|
||||||
|
width="360"
|
||||||
|
elevation="11"
|
||||||
|
>
|
||||||
|
<div class="px-8 pt-3">
|
||||||
|
<div class="d-flex align-center mt-4 pb-6">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-h6 mb-n1">{{ currentUser.displayName }}</h6>
|
||||||
|
<div class="d-flex align-center mt-2">
|
||||||
|
<IconMail
|
||||||
|
:size="18"
|
||||||
|
:stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="text-subtitle-1 font-weight-regular textSecondary ml-2">{{
|
||||||
|
currentUser.email
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-list
|
||||||
|
class="py-0 theme-list"
|
||||||
|
lines="two"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
class="py-4 px-8 custom-text-primary"
|
||||||
|
:to="{
|
||||||
|
name: 'ProfilePage',
|
||||||
|
}"
|
||||||
|
@click="closeMenu"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar color="info">
|
||||||
|
<IconUserCircle></IconUserCircle>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">My Profile</h6>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtitle-1 font-weight-regular textSecondary">Manage your information</p>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-if="isSystemAdmin"
|
||||||
|
class="py-4 px-8 custom-text-primary"
|
||||||
|
:to="{
|
||||||
|
name: 'administration/AdministrationDashboardPage',
|
||||||
|
}"
|
||||||
|
@click="closeMenu"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar color="warning">
|
||||||
|
<IconSettings></IconSettings>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">
|
||||||
|
ROTYR System Administration
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtitle-1 font-weight-regular textSecondary">Manage this application</p>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-if="isSystemAdmin"
|
||||||
|
class="py-4 px-8 custom-text-primary"
|
||||||
|
:to="{
|
||||||
|
name: 'StatusPage',
|
||||||
|
}"
|
||||||
|
@click="closeMenu"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar color="secondary">
|
||||||
|
<IconClock />
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">Version</h6>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtitle-1 font-weight-regular textSecondary">
|
||||||
|
{{ releaseTag || "2024.08.29" }}
|
||||||
|
</p>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<div class="pt-4 pb-6 px-8 text-center">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
@click="signOut"
|
||||||
|
>Logout</v-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</v-sheet>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { IconClock, IconMail, IconSettings, IconUserCircle } from "@tabler/icons-vue"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
import { isEmpty, isNil } from "lodash"
|
||||||
|
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useStatus from "@/use/use-status"
|
||||||
|
|
||||||
|
const { logout } = useAuth0()
|
||||||
|
const { currentUser, isSystemAdmin, reset: resetCurrentUser } = useCurrentUser<true>()
|
||||||
|
const showMenu = ref(false)
|
||||||
|
|
||||||
|
const { releaseTag } = useStatus()
|
||||||
|
|
||||||
|
const currentUserInitials = computed(() => {
|
||||||
|
if (isNil(currentUser.value)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName, email } = currentUser.value
|
||||||
|
if (!isNil(firstName) && !isEmpty(firstName) && !isNil(lastName) && !isEmpty(lastName)) {
|
||||||
|
const initials = [firstName, lastName].map((name) => name[0]?.toUpperCase()).join("")
|
||||||
|
return initials
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = email
|
||||||
|
.split(".")
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join("")
|
||||||
|
return initials
|
||||||
|
})
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
resetCurrentUser()
|
||||||
|
|
||||||
|
const returnTo = encodeURI(window.location.origin)
|
||||||
|
return logout({ logoutParams: { returnTo } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu
|
||||||
|
v-model="showMenu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
class="search_popup"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
class="custom-hover-primary mr-2"
|
||||||
|
size="small"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<IconSearch :size="26" />
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-sheet
|
||||||
|
width="360"
|
||||||
|
elevation="11"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<v-form class="d-flex flex-column pa-5">
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
placeholder="Search"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
@click:clear="search = ''"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
class="mt-2"
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-magnify</v-icon> Search
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
<h5 class="text-h5 mt-4 px-5 pb-4">Recently Viewed</h5>
|
||||||
|
<v-divider />
|
||||||
|
</v-sheet>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
import { IconSearch } from "@tabler/icons-vue"
|
||||||
|
|
||||||
|
const showMenu = ref(false)
|
||||||
|
|
||||||
|
const search = ref("")
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<v-skeleton-loader
|
||||||
|
v-if="isNil(user)"
|
||||||
|
type="card"
|
||||||
|
/>
|
||||||
|
<HeaderActionsFormCard
|
||||||
|
v-else
|
||||||
|
ref="headerActionsFormCard"
|
||||||
|
title="User Details"
|
||||||
|
elevation="10"
|
||||||
|
@submit.prevent="saveWrapper"
|
||||||
|
>
|
||||||
|
<template #header-actions> </template>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-label class="mb-2">First name *</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="user.firstName"
|
||||||
|
hide-details="auto"
|
||||||
|
:rules="[required]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-label class="mb-2">Last name *</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="user.lastName"
|
||||||
|
hide-details="auto"
|
||||||
|
:rules="[required]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-label class="mb-2">Display name *</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="user.displayName"
|
||||||
|
hide-details="auto"
|
||||||
|
:rules="[required]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-label class="mb-2">Email</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="user.email"
|
||||||
|
type="email"
|
||||||
|
hide-details="auto"
|
||||||
|
:rules="[required, email]"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-label class="mb-2">Roles</v-label>
|
||||||
|
<UserRoleSelect
|
||||||
|
v-model="user.roles"
|
||||||
|
hide-details="auto"
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="d-flex mt-8">
|
||||||
|
<v-btn
|
||||||
|
:loading="isLoading"
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save User
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="warning"
|
||||||
|
class="ml-4"
|
||||||
|
variant="outlined"
|
||||||
|
:loading="isLoading"
|
||||||
|
:to="{ name: 'administration/UsersPage' }"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="canDelete"
|
||||||
|
color="error"
|
||||||
|
class="ml-4"
|
||||||
|
variant="outlined"
|
||||||
|
:loading="isDeleting"
|
||||||
|
@click="showDeleteDialog = true"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HeaderActionsFormCard>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-model="showDeleteDialog"
|
||||||
|
title="Delete user?"
|
||||||
|
:message="`Permanently delete ${user?.displayName || user?.email}? This removes the user from all organizations and roles and cannot be undone.`"
|
||||||
|
confirm-text="Delete"
|
||||||
|
confirm-color="error"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, toRefs } from "vue"
|
||||||
|
import { RouteLocationRaw, useRouter } from "vue-router"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import { email, required } from "@/utils/validators"
|
||||||
|
|
||||||
|
import usersApi from "@/api/users-api"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
import useUser from "@/use/use-user"
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog.vue"
|
||||||
|
import HeaderActionsFormCard from "@/components/common/HeaderActionsFormCard.vue"
|
||||||
|
import UserRoleSelect from "@/components/users/UserRoleSelect.vue"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
userId: number
|
||||||
|
returnTo?: RouteLocationRaw
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
returnTo: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: [userId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { userId } = toRefs(props)
|
||||||
|
const { user, policy, isLoading, save } = useUser(userId)
|
||||||
|
|
||||||
|
const { currentUser } = useCurrentUser()
|
||||||
|
const router = useRouter()
|
||||||
|
const headerActionsFormCard = ref<InstanceType<typeof HeaderActionsFormCard> | null>(null)
|
||||||
|
const snack = useSnack()
|
||||||
|
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
|
const canDelete = computed(() => {
|
||||||
|
if (!policy.value?.destroy) return false
|
||||||
|
if (currentUser.value?.id === userId.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (isNil(user.value)) return
|
||||||
|
|
||||||
|
isDeleting.value = true
|
||||||
|
try {
|
||||||
|
await usersApi.delete(user.value.id)
|
||||||
|
snack.success("User deleted.")
|
||||||
|
await router.push({ name: "administration/UsersPage" })
|
||||||
|
} catch (error) {
|
||||||
|
snack.error(`Failed to delete user: ${error}`)
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWrapper() {
|
||||||
|
if (isNil(user.value)) return
|
||||||
|
if (headerActionsFormCard.value === null) return
|
||||||
|
|
||||||
|
const { valid } = await headerActionsFormCard.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await save()
|
||||||
|
snack.success("User saved!")
|
||||||
|
emit("saved", user.value.id)
|
||||||
|
} catch (error) {
|
||||||
|
snack.error(`Failed to save user: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<v-skeleton-loader
|
||||||
|
v-if="isNil(user)"
|
||||||
|
type="card"
|
||||||
|
/>
|
||||||
|
<HeaderActionsCard
|
||||||
|
v-else
|
||||||
|
title="User Details"
|
||||||
|
elevation="10"
|
||||||
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<v-btn
|
||||||
|
:to="{
|
||||||
|
name: 'profile/ProfileEditPage',
|
||||||
|
}"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<DescriptionElement
|
||||||
|
label="First name"
|
||||||
|
:model-value="user.firstName || '<blank>'"
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<DescriptionElement
|
||||||
|
label="Last name"
|
||||||
|
:model-value="user.lastName || '<blank>'"
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<DescriptionElement
|
||||||
|
label="Email"
|
||||||
|
:model-value="user.email"
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<DescriptionElement
|
||||||
|
label="Display name"
|
||||||
|
:model-value="user.displayName || '<blank>'"
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
</HeaderActionsCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { toRefs } from "vue"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import useUser from "@/use/use-user"
|
||||||
|
|
||||||
|
import HeaderActionsCard from "@/components/common/HeaderActionsCard.vue"
|
||||||
|
import DescriptionElement from "@/components/common/DescriptionElement.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
userId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { userId } = toRefs(props)
|
||||||
|
const { user } = useUser(userId)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<v-select
|
||||||
|
v-model="selectedRoles"
|
||||||
|
:items="roleItems"
|
||||||
|
label="Roles"
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
v-bind="$attrs"
|
||||||
|
></v-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
|
||||||
|
import { UserRoles } from "@/api/users-api"
|
||||||
|
|
||||||
|
const selectedRoles = defineModel<UserRoles[]>({
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const ORDERED_ROLES = [UserRoles.USER, UserRoles.SYSTEM_ADMIN]
|
||||||
|
|
||||||
|
const roleItems = Object.values(ORDERED_ROLES).map((value) => ({
|
||||||
|
title: t(`user.roles.${value}`),
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<v-data-table-server
|
||||||
|
v-model:items-per-page="perPage"
|
||||||
|
:page="page"
|
||||||
|
:headers="headers"
|
||||||
|
:items="users"
|
||||||
|
:items-length="totalCount"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click:row="rowClicked"
|
||||||
|
@update:page="updatePage"
|
||||||
|
>
|
||||||
|
<template #item.roles="{ item }"> {{ item.roles?.join(", ") }} affaafafa </template>
|
||||||
|
<template #item.updatedAt="{ value }">{{ formatDate(value) }}</template>
|
||||||
|
<template #item.createdAt="{ value }">{{ formatDate(value) }}</template>
|
||||||
|
<template
|
||||||
|
v-for="(_, name) in $slots"
|
||||||
|
:key="name"
|
||||||
|
#[name]="slotProps"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:name="name"
|
||||||
|
v-bind="slotProps"
|
||||||
|
></slot>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const DEFAULT_HEADERS = [
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
key: "displayName",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Email",
|
||||||
|
key: "email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Roles",
|
||||||
|
key: "roles",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import { formatDate } from "@/utils/formatters"
|
||||||
|
|
||||||
|
import useUsers, {
|
||||||
|
User,
|
||||||
|
UserFiltersOptions,
|
||||||
|
UserQueryOptions,
|
||||||
|
UserWhereOptions,
|
||||||
|
} from "@/use/use-users"
|
||||||
|
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
headers?: { title: string; key: string }[]
|
||||||
|
filters?: UserFiltersOptions
|
||||||
|
where?: UserWhereOptions
|
||||||
|
waiting?: boolean
|
||||||
|
routeQuerySuffix?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
headers: () => DEFAULT_HEADERS,
|
||||||
|
filters: () => ({}),
|
||||||
|
where: () => ({}),
|
||||||
|
waiting: false,
|
||||||
|
routeQuerySuffix: "Users",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: "Users", perPage: 100 })
|
||||||
|
|
||||||
|
const userQueryOptions = computed<UserQueryOptions>(() => {
|
||||||
|
return {
|
||||||
|
where: props.where,
|
||||||
|
filters: props.filters,
|
||||||
|
perPage: perPage.value,
|
||||||
|
page: page.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { users, totalCount, isLoading, refresh } = useUsers(userQueryOptions, {
|
||||||
|
skipWatchIf: () => props.waiting,
|
||||||
|
})
|
||||||
|
|
||||||
|
type UserTableRow = {
|
||||||
|
item: User
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{ clicked: [userId: User] }>()
|
||||||
|
|
||||||
|
function rowClicked(_event: unknown, row: UserTableRow) {
|
||||||
|
emit("clicked", row.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePage(newPage: number) {
|
||||||
|
if (isLoading.value || props.waiting) return
|
||||||
|
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { stripTrailingSlash } from "@/utils/strip-trailing-slash"
|
||||||
|
|
||||||
|
export const ENVIRONMENT = import.meta.env.MODE
|
||||||
|
|
||||||
|
const prodConfig = {
|
||||||
|
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
|
||||||
|
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
|
||||||
|
audience: "alphane-api",
|
||||||
|
apiBaseUrl: "",
|
||||||
|
webSocketBaseUrl: "",
|
||||||
|
applicationName: "ALPHANE",
|
||||||
|
}
|
||||||
|
|
||||||
|
const devConfig = {
|
||||||
|
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
|
||||||
|
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
|
||||||
|
audience: "alphane-api",
|
||||||
|
apiBaseUrl: "http://localhost:3000",
|
||||||
|
webSocketBaseUrl: "ws://localhost:3000",
|
||||||
|
applicationName: "ALPHANE",
|
||||||
|
}
|
||||||
|
|
||||||
|
const localProductionConfig = {
|
||||||
|
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
|
||||||
|
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
|
||||||
|
audience: "alphane-api",
|
||||||
|
apiBaseUrl: "http://localhost:8080",
|
||||||
|
webSocketBaseUrl: "ws://localhost:8080",
|
||||||
|
applicationName: "ALPHANE (production)",
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = prodConfig
|
||||||
|
|
||||||
|
if (ENVIRONMENT === "production" && window.location.host === "localhost:8080") {
|
||||||
|
config = localProductionConfig
|
||||||
|
} else if (window.location.host === "localhost:8080") {
|
||||||
|
config = devConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APPLICATION_NAME = config.applicationName
|
||||||
|
|
||||||
|
export const API_BASE_URL = config.apiBaseUrl
|
||||||
|
export const WEB_SOCKET_BASE_URL = config.webSocketBaseUrl
|
||||||
|
|
||||||
|
export const AUTH0_DOMAIN = stripTrailingSlash(config.domain)
|
||||||
|
export const AUTH0_AUDIENCE = config.audience
|
||||||
|
export const AUTH0_CLIENT_ID = config.clientId
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { visible } from "./visible"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
visible,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { DirectiveBinding } from "vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides an element using visibility property based on a boolean value.
|
||||||
|
*
|
||||||
|
* This makes helps loaders avoid downshift flickering when they trigger.
|
||||||
|
*
|
||||||
|
* See https://vuejs.org/guide/reusability/custom-directives.html#function-shorthand
|
||||||
|
*
|
||||||
|
* @usage
|
||||||
|
* ```html
|
||||||
|
* <v-progress-linear
|
||||||
|
* v-visible="isAnyDashboardLoading"
|
||||||
|
* indeterminate
|
||||||
|
* height="2"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function visible(el: HTMLElement, binding: DirectiveBinding) {
|
||||||
|
if (binding.value) {
|
||||||
|
el.style.visibility = "visible"
|
||||||
|
} else {
|
||||||
|
el.style.visibility = "hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default visible
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<v-layout>
|
||||||
|
<DefaultAppBar @toggle-ai-panel="showAiPanel = !showAiPanel" />
|
||||||
|
<!--
|
||||||
|
<LeftSidebarNavigationDrawer /> -->
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<!-- Provides the application the proper gutter -->
|
||||||
|
<v-container
|
||||||
|
fluid
|
||||||
|
class="h-100"
|
||||||
|
>
|
||||||
|
<h2 class="text-h3 mt-3">{{ title }}</h2>
|
||||||
|
<ExactingBreadcrumbs
|
||||||
|
class="pl-0 pt-2 mb-1"
|
||||||
|
:items="breadcrumbs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<router-view />
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
|
||||||
|
<AiChatPanel
|
||||||
|
v-if="showAiPanel"
|
||||||
|
v-model="showAiPanel"
|
||||||
|
/>
|
||||||
|
</v-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
|
||||||
|
import DefaultAppBar from "@/components/layout/DefaultAppBar.vue"
|
||||||
|
import ExactingBreadcrumbs from "@/components/layout/ExactingBreadcrumbs.vue"
|
||||||
|
//import LeftSidebarNavigationDrawer from "@/components/administration-layout/LeftSidebarNavigationDrawer.vue"
|
||||||
|
|
||||||
|
const { title, breadcrumbs } = useBreadcrumbs(undefined, undefined, {
|
||||||
|
baseCrumb: {
|
||||||
|
title: "Administration Dashboard",
|
||||||
|
to: {
|
||||||
|
name: "administration/AdministrationDashboardPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const showAiPanel = ref(false)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<DefaultAppBar />
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<v-container
|
||||||
|
fluid
|
||||||
|
:class="mobile ? 'pa-2' : 'pa-4'"
|
||||||
|
>
|
||||||
|
<router-view />
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
|
||||||
|
import DefaultAppBar from "@/components/layout/DefaultAppBar.vue"
|
||||||
|
|
||||||
|
const { mobile } = useDisplay()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col style="position: relative">
|
||||||
|
<div class="d-flex align-start justify-space-between">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h2 class="text-h3 mb-n2">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ExactingBreadcrumbs
|
||||||
|
class="pl-0 pt-2 mb-1"
|
||||||
|
:items="breadcrumbs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
v-if="showBackButton && !mobile"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-arrow-left"
|
||||||
|
size="small"
|
||||||
|
class="ml-2 mt-4"
|
||||||
|
color="warning"
|
||||||
|
@click="router.back()"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showBackButton && mobile"
|
||||||
|
class="mt-n5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="d-inline-flex align-center text-primary text-decoration-none"
|
||||||
|
href="#"
|
||||||
|
@click.prevent="router.back()"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="small"
|
||||||
|
class="mr-1"
|
||||||
|
>
|
||||||
|
mdi-arrow-left
|
||||||
|
</v-icon>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import ExactingBreadcrumbs from "@/components/layout/ExactingBreadcrumbs.vue"
|
||||||
|
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
|
const { mobile } = useDisplay()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { title, breadcrumbs, showBackButton } = useBreadcrumbs(undefined, undefined, {
|
||||||
|
baseCrumb: {
|
||||||
|
title: "Dashboard",
|
||||||
|
to: {
|
||||||
|
name: "DashboardPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<UserProfileCard :user-id="currentUser.id" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<router-view />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
import UserProfileCard from "@/components/users/UserProfileCard.vue"
|
||||||
|
|
||||||
|
const { currentUser } = useCurrentUser<true>()
|
||||||
|
|
||||||
|
useBreadcrumbs("My Profile", [
|
||||||
|
{
|
||||||
|
title: "My Profile",
|
||||||
|
to: {
|
||||||
|
name: "ProfilePage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logo">
|
||||||
|
<RouterLink
|
||||||
|
to="/dashboard"
|
||||||
|
class="d-flex"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="ml-0 mt-1"
|
||||||
|
style="height: 36px; transform: rotate(-12deg)"
|
||||||
|
:src="AlphaneLogo"
|
||||||
|
/>
|
||||||
|
<div v-if="sidebarMini || mdAndDown"></div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="d-flex"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mt-1 ml-3"
|
||||||
|
style="font-size: 26px; color: #505682"
|
||||||
|
>
|
||||||
|
ALPHANE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AlphaneLogo from "@/assets/alphane_logo_small.png"
|
||||||
|
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
import useInterface from "@/use/use-interface"
|
||||||
|
import { watch } from "vue"
|
||||||
|
|
||||||
|
const { sidebarMini, setSidebarMini } = useInterface()
|
||||||
|
const { mdAndDown } = useDisplay()
|
||||||
|
|
||||||
|
watch(mdAndDown, (newVal) => {
|
||||||
|
if (newVal === true) setSidebarMini(false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo a {
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
user: {
|
||||||
|
roles: {
|
||||||
|
system_admin: "System Admin",
|
||||||
|
user: "User",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createApp } from "vue"
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
import vuetify from "@/plugins/vuetify-plugin"
|
||||||
|
import auth0 from "@/plugins/auth0-plugin"
|
||||||
|
import vueI18nPlugin from "@/plugins/vue-i18n-plugin"
|
||||||
|
|
||||||
|
import "@/scss/style.scss"
|
||||||
|
import VueScrollTo from "vue-scrollto"
|
||||||
|
import directives from "@/directives"
|
||||||
|
import router from "@/router"
|
||||||
|
|
||||||
|
import App from "@/App.vue"
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router).use(vuetify).use(auth0).use(vueI18nPlugin)
|
||||||
|
|
||||||
|
app.directive("visible", directives.visible)
|
||||||
|
|
||||||
|
app.mount("#app")
|
||||||
|
|
||||||
|
app.use(VueScrollTo, {
|
||||||
|
duration: 1000,
|
||||||
|
easing: "ease",
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<PageLoader message="ALPHANE" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
|
import PageLoader from "@/components/common/PageLoader.vue"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const timeout = ref<number>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timeout.value = setTimeout(() => {
|
||||||
|
router.push({ name: "DashboardPage" })
|
||||||
|
}, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimeout(timeout.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isSystemAdmin">
|
||||||
|
<AppCard :to="{ name: 'administration/DashboardPage' }">You are a system admin</AppCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
import AppCard from "@/components/common/AppCard.vue"
|
||||||
|
|
||||||
|
const { isSystemAdmin } = useCurrentUser<true>()
|
||||||
|
|
||||||
|
useBreadcrumbs()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<UserProfileCard :user-id="currentUser.id" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
import UserProfileCard from "@/components/users/UserProfileCard.vue"
|
||||||
|
|
||||||
|
const { currentUser } = useCurrentUser<true>()
|
||||||
|
|
||||||
|
useBreadcrumbs("My Profile", [
|
||||||
|
{
|
||||||
|
title: "My Profile",
|
||||||
|
to: {
|
||||||
|
name: "ProfilePage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pa-3">
|
||||||
|
<v-row class="h-100vh auth">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
lg="5"
|
||||||
|
xl="4"
|
||||||
|
class="d-flex align-center justify-center bg-surface"
|
||||||
|
>
|
||||||
|
<div class="mt-xl-0 mt-5 mw-100 text-center">
|
||||||
|
<div
|
||||||
|
class="px-8"
|
||||||
|
style="max-width: 500px"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="d-inline-block d-lg-none"
|
||||||
|
src="@/assets/app_logo_small.png"
|
||||||
|
style="height: 66px; transform: rotate(-12deg)"
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
class="text-h1 textPrimary font-weight-semibold mb-0"
|
||||||
|
style="font-size: 2.5rem !important"
|
||||||
|
>
|
||||||
|
ALPHANE
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="card-subtitle mb-6"
|
||||||
|
style="font-size: 1.2rem"
|
||||||
|
>
|
||||||
|
Helicopter Maintenance Tracking Platform
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
This application is will streamline maintenance control decision making and unlock
|
||||||
|
operational constraints.
|
||||||
|
</p>
|
||||||
|
<h6 class="text-h6 text-medium-emphasis d-flex align-center mt-6 font-weight-medium">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
@click="doLogin"
|
||||||
|
>Sign in</v-btn
|
||||||
|
>
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div class="my-6 text-medium-emphasis">
|
||||||
|
If you don't have an account, create one to get started using ROTYR. It's free to sign
|
||||||
|
up and only takes a few seconds.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <v-btn
|
||||||
|
block
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
class="mt-3"
|
||||||
|
:to="{ name: 'SignUpPage' }"
|
||||||
|
text="Sign Up"
|
||||||
|
/> -->
|
||||||
|
<div
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
class="mt-5 text-center"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="warning"
|
||||||
|
variant="text"
|
||||||
|
@click="logoutWrapper"
|
||||||
|
>Sign out</v-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
lg="7"
|
||||||
|
xl="8"
|
||||||
|
class="d-none d-lg-flex align-center justify-center authentication position-relative"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<img
|
||||||
|
src="@/assets/app_logo_splash.png"
|
||||||
|
class="position-relative"
|
||||||
|
style="opacity: 0.15; width: 80%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
const { reset: resetCurrentUser } = useCurrentUser()
|
||||||
|
const { isAuthenticated, loginWithRedirect, logout } = useAuth0()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resetCurrentUser()
|
||||||
|
})
|
||||||
|
|
||||||
|
function doLogin() {
|
||||||
|
loginWithRedirect({
|
||||||
|
appState: { target: "/dashboard" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function logoutWrapper() {
|
||||||
|
await logout({
|
||||||
|
logoutParams: {
|
||||||
|
returnTo: window.location.origin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-container>
|
||||||
|
Return to <router-link :to="{ name: returnTo.name }">{{ returnTo.title }}</router-link>
|
||||||
|
|
||||||
|
<v-row class="mt-5">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card
|
||||||
|
outlined
|
||||||
|
class="pa-3"
|
||||||
|
:loading="isLoading"
|
||||||
|
>
|
||||||
|
<v-card-title
|
||||||
|
>Environment Information
|
||||||
|
<v-btn
|
||||||
|
class="ma-0 ml-1"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
title="refresh"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-cached</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-list dense>
|
||||||
|
<v-list-item> Release Tag: {{ environment.releaseTag }} </v-list-item>
|
||||||
|
<v-list-item> Git Commit Hash: {{ environment.gitCommitHash }} </v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import http from "@/api/http-client"
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuth0()
|
||||||
|
|
||||||
|
const returnTo = computed<{ name: string; title: string }>(() => {
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
return {
|
||||||
|
name: "DashboardPage",
|
||||||
|
title: "Dashboard",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "SignInPage",
|
||||||
|
title: "Sign In",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const environment = reactive({
|
||||||
|
releaseTag: "not-set",
|
||||||
|
gitCommitHash: "not-set",
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchVersion() {
|
||||||
|
return http
|
||||||
|
.get("/_status")
|
||||||
|
.then(({ data }) => {
|
||||||
|
environment.releaseTag = data.RELEASE_TAG
|
||||||
|
environment.gitCommitHash = data.GIT_COMMIT_HASH
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(`Error fetching version: ${error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
await fetchVersion()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching version: ${error}`)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-row>
|
||||||
|
<!-- Users Card -->
|
||||||
|
<v-col
|
||||||
|
v-if="isSystemAdmin"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
elevation="10"
|
||||||
|
:to="{ name: 'administration/UsersPage' }"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<v-card-item>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar
|
||||||
|
color="success"
|
||||||
|
size="56"
|
||||||
|
class="mr-4"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="32"
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
mdi-account-group
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<v-card-title class="text-h5">Users</v-card-title>
|
||||||
|
<v-card-subtitle>Manage users and permissions</v-card-subtitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center justify-space-between">
|
||||||
|
<span class="text-h3 font-weight-bold">{{ usersCount }}</span>
|
||||||
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
mdi-chevron-right
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis mt-1">Total Users</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Settings Card -->
|
||||||
|
<v-col
|
||||||
|
v-if="isSystemAdmin"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
elevation="10"
|
||||||
|
:to="{ name: 'administration/SettingsPage' }"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<v-card-item>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar
|
||||||
|
color="info"
|
||||||
|
size="56"
|
||||||
|
class="mr-4"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="32"
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
mdi-cog
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<v-card-title class="text-h5">Settings</v-card-title>
|
||||||
|
<v-card-subtitle>System configuration</v-card-subtitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center justify-space-between">
|
||||||
|
<span class="text-h3 font-weight-bold"> </span>
|
||||||
|
|
||||||
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
mdi-chevron-right
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis mt-1">Configure System</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useUsers from "@/use/use-users"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
const { isSystemAdmin } = useCurrentUser()
|
||||||
|
|
||||||
|
const { totalCount: usersCount } = useUsers()
|
||||||
|
|
||||||
|
useBreadcrumbs("Administration Dashboard", [])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<template><div></div></template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
|
||||||
|
useBreadcrumbs("Settings", [
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
to: {
|
||||||
|
name: "administration/SettingsPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<HeaderActionsCard
|
||||||
|
title="Users"
|
||||||
|
elevation="10"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-if="isSystemAdmin"
|
||||||
|
#header-actions
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="secondary"
|
||||||
|
:to="{
|
||||||
|
name: 'administration/users/UserNewPage',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<v-icon class="mr-3">mdi-plus</v-icon>
|
||||||
|
Add User
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UsersDataTableServer />
|
||||||
|
</HeaderActionsCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
import HeaderActionsCard from "@/components/common/HeaderActionsCard.vue"
|
||||||
|
import UsersDataTableServer from "@/components/users/UsersDataTableServer.vue"
|
||||||
|
|
||||||
|
const { isSystemAdmin } = useCurrentUser()
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{
|
||||||
|
title: "Users",
|
||||||
|
to: {
|
||||||
|
name: "administration/UsersPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
useBreadcrumbs("Users", breadcrumbs)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<UserEditCardForm
|
||||||
|
:user-id="userId"
|
||||||
|
:return-to="{
|
||||||
|
name: 'administration/users/UserPage',
|
||||||
|
params: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
@saved="refreshUserOrCurrentUser"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useUser from "@/use/use-user"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
|
||||||
|
import UserEditCardForm from "@/components/users/UserEditCardForm.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
userId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const userId = computed(() => parseInt(props.userId))
|
||||||
|
const { user, refresh } = useUser(userId)
|
||||||
|
|
||||||
|
const { currentUser, refresh: refreshCurrentUser } = useCurrentUser<true>()
|
||||||
|
const snack = useSnack()
|
||||||
|
|
||||||
|
async function refreshUserOrCurrentUser() {
|
||||||
|
if (userId.value === currentUser.value.id) {
|
||||||
|
await refreshCurrentUser()
|
||||||
|
snack.info("Logged-in user updated. App refreshed.")
|
||||||
|
} else {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Users",
|
||||||
|
to: {
|
||||||
|
name: "administration/UsersPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: user.value?.displayName || user.value?.email,
|
||||||
|
to: {
|
||||||
|
name: "administration/users/UserPage",
|
||||||
|
params: {
|
||||||
|
userId: props.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
useBreadcrumbs("User Details", breadcrumbs)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<template>
|
||||||
|
<HeaderActionsFormCard
|
||||||
|
ref="headerActionsFormCard"
|
||||||
|
title="New User"
|
||||||
|
@submit.prevent="createUser"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="userAttributes.firstName"
|
||||||
|
label="First name *"
|
||||||
|
required
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="userAttributes.lastName"
|
||||||
|
label="Last name *"
|
||||||
|
required
|
||||||
|
:rules="[required]"
|
||||||
|
@update:focused="autoFillDependentFields"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="userAttributes.displayName"
|
||||||
|
label="Display name *"
|
||||||
|
required
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<UserEmailUniqueTextField
|
||||||
|
v-model="userAttributes.email"
|
||||||
|
label="Email *"
|
||||||
|
:rules="[required, minimum(2), email]"
|
||||||
|
required
|
||||||
|
validate-on="blur"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="userAttributes.title"
|
||||||
|
label="Title"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="userAttributes.pilotLicense"
|
||||||
|
label="Pilot License"
|
||||||
|
placeholder="e.g., CPL-123456"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="userAttributes.ameLicense"
|
||||||
|
label="AME License"
|
||||||
|
placeholder="e.g., AME-789012"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<h4 class="mt-3">Images</h4>
|
||||||
|
<v-divider class="mt-1 mb-2" />
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-file-input
|
||||||
|
v-model="profileImageFile"
|
||||||
|
accept="image/*"
|
||||||
|
prepend-icon="mdi-camera"
|
||||||
|
label="Upload Profile Image"
|
||||||
|
clearable
|
||||||
|
@change="handleProfileImageUpload"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="profileImagePreview"
|
||||||
|
class="mt-4 d-flex justify-center"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
:src="profileImagePreview"
|
||||||
|
max-width="200"
|
||||||
|
max-height="200"
|
||||||
|
class="rounded elevation-2"
|
||||||
|
cover
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-file-input
|
||||||
|
v-model="signatureImageFile"
|
||||||
|
accept="image/*"
|
||||||
|
prepend-icon="mdi-draw"
|
||||||
|
label="Upload Signature"
|
||||||
|
clearable
|
||||||
|
@change="handleSignatureImageUpload"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="signatureImagePreview"
|
||||||
|
class="mt-4 d-flex justify-center"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
:src="signatureImagePreview"
|
||||||
|
max-width="300"
|
||||||
|
max-height="150"
|
||||||
|
class="rounded elevation-2"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<h4 class="mt-3">Notification Details</h4>
|
||||||
|
<v-divider class="mt-1 mb-2" />
|
||||||
|
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-switch
|
||||||
|
v-model="userAttributes.emailNotificationsEnabled"
|
||||||
|
label="Email notifications enabled?"
|
||||||
|
inset
|
||||||
|
center-affix
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="d-flex">
|
||||||
|
<v-btn
|
||||||
|
:loading="isLoading"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="warning"
|
||||||
|
class="ml-4"
|
||||||
|
variant="outlined"
|
||||||
|
:loading="isLoading"
|
||||||
|
:to="{
|
||||||
|
name: 'administration/UsersPage',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HeaderActionsFormCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { isEmpty, isNil } from "lodash"
|
||||||
|
|
||||||
|
import { required, minimum, email } from "@/utils/validators"
|
||||||
|
import { resizeToStandard } from "@/utils/image-resizer"
|
||||||
|
import usersApi, { User } from "@/api/users-api"
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
|
||||||
|
import HeaderActionsFormCard from "@/components/shared/cards/HeaderActionsFormCard.vue"
|
||||||
|
import UserEmailUniqueTextField from "@/components/users/UserEmailUniqueTextField.vue"
|
||||||
|
|
||||||
|
const userAttributes = ref<Partial<User>>({
|
||||||
|
email: "",
|
||||||
|
displayName: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
title: null,
|
||||||
|
pilotLicense: null,
|
||||||
|
ameLicense: null,
|
||||||
|
emailNotificationsEnabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Image handling
|
||||||
|
const profileImageFile = ref<File[] | File | null>(null)
|
||||||
|
const signatureImageFile = ref<File[] | File | null>(null)
|
||||||
|
const profileImagePreview = ref<string | null>(null)
|
||||||
|
const signatureImagePreview = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function handleProfileImageUpload() {
|
||||||
|
if (!profileImageFile.value) {
|
||||||
|
profileImagePreview.value = null
|
||||||
|
userAttributes.value.profileImage = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle both array and single file
|
||||||
|
const file = Array.isArray(profileImageFile.value)
|
||||||
|
? profileImageFile.value[0]
|
||||||
|
: profileImageFile.value
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
profileImagePreview.value = null
|
||||||
|
userAttributes.value.profileImage = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have a valid File object
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
console.error("Invalid file type:", file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resize image to 512x512 before upload
|
||||||
|
const base64DataUrl = await resizeToStandard(file)
|
||||||
|
profileImagePreview.value = base64DataUrl
|
||||||
|
userAttributes.value.profileImage = base64DataUrl
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resizing profile image:", error)
|
||||||
|
snack.error("Failed to process profile image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignatureImageUpload() {
|
||||||
|
if (!signatureImageFile.value) {
|
||||||
|
signatureImagePreview.value = null
|
||||||
|
userAttributes.value.signatureImage = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle both array and single file
|
||||||
|
const file = Array.isArray(signatureImageFile.value)
|
||||||
|
? signatureImageFile.value[0]
|
||||||
|
: signatureImageFile.value
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
signatureImagePreview.value = null
|
||||||
|
userAttributes.value.signatureImage = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have a valid File object
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
console.error("Invalid file type:", file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resize image to 512x512 before upload
|
||||||
|
const base64DataUrl = await resizeToStandard(file)
|
||||||
|
signatureImagePreview.value = base64DataUrl
|
||||||
|
userAttributes.value.signatureImage = base64DataUrl
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resizing signature image:", error)
|
||||||
|
snack.error("Failed to process signature image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoFillDependentFields(focused: boolean) {
|
||||||
|
if (focused) return
|
||||||
|
|
||||||
|
const { email, displayName, firstName, lastName } = userAttributes.value
|
||||||
|
if ((isNil(firstName) || isEmpty(firstName)) && (isNil(lastName) || isEmpty(lastName))) return
|
||||||
|
|
||||||
|
if (isNil(displayName) || isEmpty(displayName)) {
|
||||||
|
userAttributes.value.displayName = [firstName, lastName].filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(email) || isEmpty(email)) {
|
||||||
|
userAttributes.value.email = `${firstName}.${lastName}@unknown.ca`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerActionsFormCard = ref<InstanceType<typeof HeaderActionsFormCard> | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const snack = useSnack()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
if (headerActionsFormCard.value === null) return
|
||||||
|
|
||||||
|
const { valid } = await headerActionsFormCard.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
const userAttributesWithAuthSubject = {
|
||||||
|
...userAttributes.value,
|
||||||
|
authSubject: userAttributes.value.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const { user } = await usersApi.create(userAttributesWithAuthSubject)
|
||||||
|
snack.success("User created!")
|
||||||
|
return router.push({
|
||||||
|
name: "administration/users/UserPage",
|
||||||
|
params: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
snack.error(`Failed to create user: ${error}`)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useBreadcrumbs("User Creation", [
|
||||||
|
{
|
||||||
|
title: "Users",
|
||||||
|
to: {
|
||||||
|
name: "administration/UsersPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "New User",
|
||||||
|
to: {
|
||||||
|
name: "administration/users/UserNewPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-empty-state
|
||||||
|
headline="Whoops, 403"
|
||||||
|
title="Access Forbidden"
|
||||||
|
text="You do not have permission to access that page"
|
||||||
|
:image="SplashImage"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
@click="goBack"
|
||||||
|
>Back</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
|
||||||
|
<v-btn
|
||||||
|
href="/dashboard"
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
>Home</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
width="200"
|
||||||
|
@click="signOut"
|
||||||
|
>Logout</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider
|
||||||
|
class="mt-10 mb-6"
|
||||||
|
thickness="1"
|
||||||
|
/>
|
||||||
|
<p>Site: {{ APPLICATION_NAME }}</p>
|
||||||
|
<p>Version: {{ releaseTag }}</p>
|
||||||
|
<p>Commit Hash: {{ gitCommitHash }}</p>
|
||||||
|
</v-empty-state>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import SplashImage from "@/assets/SplashImage.png"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import { APPLICATION_NAME } from "@/config"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useStatus from "@/use/use-status"
|
||||||
|
|
||||||
|
const { logout } = useAuth0()
|
||||||
|
const { reset: resetCurrentUser } = useCurrentUser()
|
||||||
|
|
||||||
|
const { releaseTag, gitCommitHash } = useStatus()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
resetCurrentUser()
|
||||||
|
|
||||||
|
const returnTo = encodeURI(window.location.origin + "/sign-in")
|
||||||
|
return logout({
|
||||||
|
logoutParams: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-empty-state
|
||||||
|
headline="Whoops, 500"
|
||||||
|
title="Internal Server Error"
|
||||||
|
:text="'Oops! The server encountered an unexpected error. Please\u00a0contact\u00a0support.'"
|
||||||
|
:image="SplashImage"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
@click="goBack"
|
||||||
|
>Back</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
|
||||||
|
<v-btn
|
||||||
|
href="/dashboard"
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
>Home</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
width="200"
|
||||||
|
@click="signOut"
|
||||||
|
>Logout</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider
|
||||||
|
class="mt-10 mb-6"
|
||||||
|
thickness="1"
|
||||||
|
/>
|
||||||
|
<p>Site: {{ APPLICATION_NAME }}</p>
|
||||||
|
<p>Version: {{ releaseTag }}</p>
|
||||||
|
<p>Commit Hash: {{ gitCommitHash }}</p>
|
||||||
|
</v-empty-state>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import SplashImage from "@/assets/SplashImage.png"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import { APPLICATION_NAME } from "@/config"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useStatus from "@/use/use-status"
|
||||||
|
|
||||||
|
const { logout } = useAuth0()
|
||||||
|
const { reset: resetCurrentUser } = useCurrentUser()
|
||||||
|
|
||||||
|
const { releaseTag, gitCommitHash } = useStatus()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
resetCurrentUser()
|
||||||
|
|
||||||
|
const returnTo = encodeURI(window.location.origin + "/sign-in")
|
||||||
|
return logout({
|
||||||
|
logoutParams: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-empty-state
|
||||||
|
headline="Whoops, 404"
|
||||||
|
title="Page Not Found"
|
||||||
|
text="Oops! The page you're looking doesn't exist."
|
||||||
|
:image="SplashImage"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
@click="goBack"
|
||||||
|
>Back</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
|
||||||
|
<v-btn
|
||||||
|
href="/dashboard"
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
>Home</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
width="200"
|
||||||
|
@click="signOut"
|
||||||
|
>Logout</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider
|
||||||
|
class="mt-10 mb-6"
|
||||||
|
thickness="1"
|
||||||
|
/>
|
||||||
|
<p>Site: {{ APPLICATION_NAME }}</p>
|
||||||
|
<p>Version: {{ releaseTag }}</p>
|
||||||
|
<p>Commit Hash: {{ gitCommitHash }}</p>
|
||||||
|
</v-empty-state>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import SplashImage from "@/assets/SplashImage.png"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import { APPLICATION_NAME } from "@/config"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useStatus from "@/use/use-status"
|
||||||
|
|
||||||
|
const { logout } = useAuth0()
|
||||||
|
const { reset: resetCurrentUser } = useCurrentUser()
|
||||||
|
|
||||||
|
const { releaseTag, gitCommitHash } = useStatus()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
resetCurrentUser()
|
||||||
|
|
||||||
|
const returnTo = encodeURI(window.location.origin + "/sign-in")
|
||||||
|
return logout({
|
||||||
|
logoutParams: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-empty-state
|
||||||
|
headline="Whoops, 401"
|
||||||
|
title="Unauthorized"
|
||||||
|
text="If you think this is an error, please contact support. Alternatively, try logging out and signing back in."
|
||||||
|
:image="SplashImage"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
@click="goBack"
|
||||||
|
>Back</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
|
||||||
|
<v-btn
|
||||||
|
href="/dashboard"
|
||||||
|
color="primary"
|
||||||
|
width="200"
|
||||||
|
variant="outlined"
|
||||||
|
>Home</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="d-flex justify-center">
|
||||||
|
<v-btn
|
||||||
|
width="200"
|
||||||
|
@click="signOut"
|
||||||
|
>Logout</v-btn
|
||||||
|
>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider
|
||||||
|
class="mt-10 mb-6"
|
||||||
|
thickness="1"
|
||||||
|
/>
|
||||||
|
<p>Site: {{ APPLICATION_NAME }}</p>
|
||||||
|
<p>Version: {{ releaseTag }}</p>
|
||||||
|
<p>Commit Hash: {{ gitCommitHash }}</p>
|
||||||
|
</v-empty-state>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import SplashImage from "@/assets/SplashImage.png"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import { APPLICATION_NAME } from "@/config"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useStatus from "@/use/use-status"
|
||||||
|
|
||||||
|
const { logout } = useAuth0()
|
||||||
|
const { reset: resetCurrentUser } = useCurrentUser()
|
||||||
|
|
||||||
|
const { releaseTag, gitCommitHash } = useStatus()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
resetCurrentUser()
|
||||||
|
|
||||||
|
const returnTo = encodeURI(window.location.origin + "/sign-in")
|
||||||
|
return logout({
|
||||||
|
logoutParams: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<UserEditCardForm
|
||||||
|
:user-id="currentUser.id"
|
||||||
|
:return-to="{
|
||||||
|
name: 'ProfilePage',
|
||||||
|
}"
|
||||||
|
@saved="refreshWrapper"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
|
||||||
|
import UserEditCardForm from "@/components/users/UserEditCardForm.vue"
|
||||||
|
|
||||||
|
const { currentUser, refresh } = useCurrentUser<true>()
|
||||||
|
const snack = useSnack()
|
||||||
|
|
||||||
|
async function refreshWrapper() {
|
||||||
|
await refresh()
|
||||||
|
snack.info("Logged-in user updated. App refreshed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
useBreadcrumbs("Edit My Profile", [
|
||||||
|
{
|
||||||
|
title: "My Profile",
|
||||||
|
to: {
|
||||||
|
name: "ProfilePage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Edit",
|
||||||
|
to: {
|
||||||
|
name: "profile/ProfileEditPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import { AUTH0_AUDIENCE, AUTH0_CLIENT_ID, AUTH0_DOMAIN, ENVIRONMENT } from "@/config"
|
||||||
|
|
||||||
|
// See https://auth0.github.io/auth0-vue/#md:add-login-to-your-application
|
||||||
|
export default createAuth0({
|
||||||
|
domain: AUTH0_DOMAIN,
|
||||||
|
clientId: AUTH0_CLIENT_ID,
|
||||||
|
authorizationParams: {
|
||||||
|
audience: AUTH0_AUDIENCE,
|
||||||
|
redirect_uri: `${window.location.origin}/callback`,
|
||||||
|
},
|
||||||
|
cacheLocation: ENVIRONMENT === "development" ? "localstorage" : "memory",
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createI18n } from "vue-i18n"
|
||||||
|
|
||||||
|
// I'd prefer to use yaml, or even json, but I can't get them to import at the moment
|
||||||
|
// This might be a TypeScript issue, or I might need a yaml plugin.
|
||||||
|
import en from "@/locales/en.js"
|
||||||
|
|
||||||
|
export default createI18n({
|
||||||
|
legacy: false, // support composition api
|
||||||
|
locale: "en",
|
||||||
|
messages: {
|
||||||
|
en,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* plugins/vuetify.js
|
||||||
|
*
|
||||||
|
* Framework documentation: https://vuetifyjs.com`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
import "@mdi/font/css/materialdesignicons.css"
|
||||||
|
import "vuetify/styles"
|
||||||
|
|
||||||
|
// ComposablesF
|
||||||
|
import { createVuetify } from "vuetify"
|
||||||
|
import * as components from "vuetify/components"
|
||||||
|
import * as directives from "vuetify/directives"
|
||||||
|
import * as labsComponents from "vuetify/labs/components"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DARK_BLUE_THEME,
|
||||||
|
DARK_AQUA_THEME,
|
||||||
|
DARK_ORANGE_THEME,
|
||||||
|
DARK_PURPLE_THEME,
|
||||||
|
DARK_GREEN_THEME,
|
||||||
|
DARK_CYAN_THEME,
|
||||||
|
} from "@/theme/DarkTheme"
|
||||||
|
|
||||||
|
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||||
|
export default createVuetify({
|
||||||
|
components: {
|
||||||
|
...components,
|
||||||
|
...labsComponents,
|
||||||
|
},
|
||||||
|
directives,
|
||||||
|
theme: {
|
||||||
|
defaultTheme: "DARK_AQUA_THEME",
|
||||||
|
themes: {
|
||||||
|
DARK_BLUE_THEME,
|
||||||
|
DARK_AQUA_THEME,
|
||||||
|
DARK_ORANGE_THEME,
|
||||||
|
DARK_PURPLE_THEME,
|
||||||
|
DARK_GREEN_THEME,
|
||||||
|
DARK_CYAN_THEME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
VCard: {
|
||||||
|
rounded: "md",
|
||||||
|
},
|
||||||
|
VTextField: {
|
||||||
|
variant: "outlined",
|
||||||
|
density: "comfortable",
|
||||||
|
color: "primary",
|
||||||
|
},
|
||||||
|
VTextarea: {
|
||||||
|
variant: "outlined",
|
||||||
|
density: "comfortable",
|
||||||
|
color: "primary",
|
||||||
|
},
|
||||||
|
VFileInput: {
|
||||||
|
variant: "outlined",
|
||||||
|
density: "comfortable",
|
||||||
|
color: "primary",
|
||||||
|
prependIcon: null,
|
||||||
|
},
|
||||||
|
VSelect: {
|
||||||
|
variant: "outlined",
|
||||||
|
density: "comfortable",
|
||||||
|
color: "primary",
|
||||||
|
},
|
||||||
|
VCombobox: {
|
||||||
|
variant: "outlined",
|
||||||
|
density: "comfortable",
|
||||||
|
color: "primary",
|
||||||
|
},
|
||||||
|
VAutocomplete: {
|
||||||
|
variant: "outlined",
|
||||||
|
density: "comfortable",
|
||||||
|
color: "primary",
|
||||||
|
},
|
||||||
|
VListItem: {
|
||||||
|
minHeight: "45px",
|
||||||
|
},
|
||||||
|
VTooltip: {
|
||||||
|
location: "top",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"
|
||||||
|
import { authGuard } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import { APPLICATION_NAME } from "@/config"
|
||||||
|
import administrationRoutes from "@/routes/administration-routes"
|
||||||
|
import { authorizationGuard } from "@/utils/authorization-guards"
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "SignInPage",
|
||||||
|
component: () => import("@/pages/SignInPage.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/callback",
|
||||||
|
name: "CallbackPage",
|
||||||
|
component: () => import("@/pages/CallbackPage.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => import("@/layouts/DefaultLayout.vue"),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
redirect: "sign-in",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DashboardPage",
|
||||||
|
path: "dashboard",
|
||||||
|
component: () => import("@/pages/DashboardPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Dashboard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
component: () => import("@/layouts/LayoutWithBreadcrumbs.vue"),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "profile",
|
||||||
|
component: () => import("@/layouts/ProfileLayout.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Profile",
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "ProfilePage",
|
||||||
|
redirect: { name: "profile/ProfileEditPage" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: "profile/edit",
|
||||||
|
name: "profile/ProfileEditPage",
|
||||||
|
component: () => import("@/pages/profile/ProfileEditPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Edit Profile",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...administrationRoutes,
|
||||||
|
{
|
||||||
|
name: "StatusPage",
|
||||||
|
path: "/status",
|
||||||
|
component: () => import("@/pages/StatusPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Status",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/errors/unauthorized",
|
||||||
|
name: "errors/UnauthorizedPage",
|
||||||
|
component: () => import("@/pages/errors/UnauthorizedPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Unauthorized",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/errors/forbidden",
|
||||||
|
name: "errors/ForbiddenPage",
|
||||||
|
component: () => import("@/pages/errors/ForbiddenPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Forbidden",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/errors/internal-server-error",
|
||||||
|
name: "errors/InternalServerErrorPage",
|
||||||
|
component: () => import("@/pages/errors/InternalServerErrorPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Internal Server Error",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/errors/not-found",
|
||||||
|
name: "errors/NotFoundPage",
|
||||||
|
component: () => import("@/pages/errors/NotFoundPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Not Found",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
redirect: "/errors/not-found",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.onError((error, to) => {
|
||||||
|
if (
|
||||||
|
error.message.includes("Failed to fetch dynamically imported module") ||
|
||||||
|
error.message.includes("Importing a module script failed")
|
||||||
|
) {
|
||||||
|
window.location.href = to.fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
if (to.meta && to.meta.title) {
|
||||||
|
document.title = `${APPLICATION_NAME} - ${to.meta.title}`
|
||||||
|
} else {
|
||||||
|
document.title = APPLICATION_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth === false) return true
|
||||||
|
|
||||||
|
const isAuthenticated = await authGuard(to)
|
||||||
|
if (!isAuthenticated) return false
|
||||||
|
|
||||||
|
const isAuthorized = await authorizationGuard(to)
|
||||||
|
if (!isAuthorized) return "/errors/forbidden"
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { RouteRecordRaw } from "vue-router"
|
||||||
|
|
||||||
|
import { isSystemAdmin } from "@/utils/authorization-guards"
|
||||||
|
|
||||||
|
export const administrationRoutes: Readonly<RouteRecordRaw[]> = [
|
||||||
|
{
|
||||||
|
path: "/administration",
|
||||||
|
component: () => import("@/layouts/AdministrationLayout.vue"),
|
||||||
|
meta: {
|
||||||
|
guards: [isSystemAdmin],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "administration/AdministrationDashboardPage",
|
||||||
|
component: () => import("@/pages/administration/AdministrationDashboardPage.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "users",
|
||||||
|
name: "administration/UsersPage",
|
||||||
|
component: () => import("@/pages/administration/UsersPage.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "users/new",
|
||||||
|
name: "administration/users/UserNewPage",
|
||||||
|
component: () => import("@/pages/administration/users/UserNewPage.vue"),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "users/:userId",
|
||||||
|
name: "administration/users/UserPage",
|
||||||
|
component: () => import("@/pages/administration/users/UserEditPage.vue"),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "users/:userId/edit",
|
||||||
|
name: "administration/users/UserEditPage",
|
||||||
|
component: () => import("@/pages/administration/users/UserEditPage.vue"),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
name: "administration/SettingsPage",
|
||||||
|
component: () => import("@/pages/administration/SettingsPage.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default administrationRoutes
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
@use "./variables" as *;
|
||||||
|
|
||||||
|
html {
|
||||||
|
.bg-success {
|
||||||
|
color: $white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary {
|
||||||
|
color: $white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-secondary {
|
||||||
|
color: $white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
color: $white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-secondary-gradient {
|
||||||
|
background: linear-gradient(287deg, rgb(var(--v-theme-primary)) .54%, #1bcaff 100.84%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.border,
|
||||||
|
.v-divider {
|
||||||
|
border-color: rgba(var(--v-border-color)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avtar-border {
|
||||||
|
border: 2px solid rgb(var(--v-theme-surface)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtext {
|
||||||
|
font-size: $font-size-root;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-dialog {
|
||||||
|
&.dialog-mw {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-40 {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-56 {
|
||||||
|
height: 56px;
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-48 {
|
||||||
|
height: 48px;
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-30 {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lh-0 {
|
||||||
|
line-height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.lh-28 {
|
||||||
|
line-height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lh-32 {
|
||||||
|
line-height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-p-96 {
|
||||||
|
padding: 96px 0 !important;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-96 {
|
||||||
|
padding-inline-start: 96px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-96 {
|
||||||
|
padding-top: 96px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-0 {
|
||||||
|
inset-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-0 {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
height: calc(100vh - 350px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-chat-height {
|
||||||
|
height: calc(-500px + 100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width:991px) {
|
||||||
|
.overflow-x-reposive {
|
||||||
|
overflow-x: scroll;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-m-none {
|
||||||
|
border: 0 !important
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-height:767px) {
|
||||||
|
.msg-chat-height {
|
||||||
|
height: calc(-315px + 100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-h-600 {
|
||||||
|
max-height: 600px;
|
||||||
|
height: calc(100vh - 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.custom-hover-primary {
|
||||||
|
.iconify {
|
||||||
|
color: rgb(255, 255, 255) !important;
|
||||||
|
|
||||||
|
@media screen and (max-width:991px) {
|
||||||
|
color: rgba(var(--v-theme-textPrimary), 0.8) !important
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(var(--v-theme-lightprimary), 0.1);
|
||||||
|
|
||||||
|
.iconify {
|
||||||
|
@media screen and (max-width:991px) {
|
||||||
|
color: rgb(var(--v-theme-primary)) !important
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.custom-hover-primary-white {
|
||||||
|
.iconify {
|
||||||
|
color: rgb(255, 255, 255) !important;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(var(--v-theme-lightprimary), 0.1);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-icon {
|
||||||
|
|
||||||
|
.v-input__prepend,
|
||||||
|
.v-input__append {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-white {
|
||||||
|
background-color: rgb(255, 255, 255) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-badge {
|
||||||
|
&.x-small-badge {
|
||||||
|
.v-badge__badge {
|
||||||
|
height: 6px !important;
|
||||||
|
width: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.one-line {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-line {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-1 {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
@use "sass:math";
|
||||||
|
@use "sass:map";
|
||||||
|
@use "sass:meta";
|
||||||
|
@use "vuetify/lib/styles/tools/functions" as *;
|
||||||
|
|
||||||
|
// Custom Variables
|
||||||
|
// colors
|
||||||
|
$white: #fff !default;
|
||||||
|
|
||||||
|
// cards
|
||||||
|
$card-title-size: 18px !default;
|
||||||
|
|
||||||
|
$body-font-family: "Overpass", sans-serif !default;
|
||||||
|
$border-radius-root: 10px;
|
||||||
|
$btn-font-weight: 400 !default;
|
||||||
|
$btn-letter-spacing: 0 !default;
|
||||||
|
|
||||||
|
// Global Shadow
|
||||||
|
$box-shadow:
|
||||||
|
rgba(145 158 171 / 30%) 0px 0px 2px 0px,
|
||||||
|
rgba(145 158 171 / 12%) 0px 12px 24px -4px;
|
||||||
|
|
||||||
|
// Global Radius as per breakeven point
|
||||||
|
|
||||||
|
@forward "vuetify/settings" with (
|
||||||
|
$color-pack: false !default,
|
||||||
|
// Global font size and border radius
|
||||||
|
$font-size-root: 1rem,
|
||||||
|
$border-radius-root: $border-radius-root,
|
||||||
|
$body-font-family: $body-font-family,
|
||||||
|
$heading-font-family: $body-font-family !default,
|
||||||
|
$button-height: 40px,
|
||||||
|
// 👉 Typography
|
||||||
|
$typography: (
|
||||||
|
"h1": (
|
||||||
|
"size": 2.25rem,
|
||||||
|
"weight": 600,
|
||||||
|
"line-height": 2.75rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"h2": (
|
||||||
|
"size": 1.875rem,
|
||||||
|
"weight": 500,
|
||||||
|
"line-height": 2.25rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"h3": (
|
||||||
|
"size": 1.5rem,
|
||||||
|
"weight": 500,
|
||||||
|
"line-height": 2rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"h4": (
|
||||||
|
"size": 1.3125rem,
|
||||||
|
"weight": 500,
|
||||||
|
"line-height": 1.6rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"h5": (
|
||||||
|
"size": 1.125rem,
|
||||||
|
"weight": 500,
|
||||||
|
"line-height": 1.6rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"h6": (
|
||||||
|
"size": 1rem,
|
||||||
|
"weight": 500,
|
||||||
|
"line-height": 1.2rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"subtitle-1": (
|
||||||
|
"size": 0.875rem,
|
||||||
|
"weight": 400,
|
||||||
|
"line-height": 1.1rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"subtitle-2": (
|
||||||
|
"size": 0.75rem,
|
||||||
|
"weight": 400,
|
||||||
|
"line-height": 1rem,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"body-1": (
|
||||||
|
"size": 0.875rem,
|
||||||
|
"weight": 400,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"body-2": (
|
||||||
|
"size": 0.75rem,
|
||||||
|
"weight": 400,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"button": (
|
||||||
|
"size": 0.875rem,
|
||||||
|
"weight": 500,
|
||||||
|
"font-family": inherit,
|
||||||
|
"text-transform": capitalize,
|
||||||
|
),
|
||||||
|
"caption": (
|
||||||
|
"size": 0.75rem,
|
||||||
|
"weight": 400,
|
||||||
|
"font-family": inherit,
|
||||||
|
),
|
||||||
|
"overline": (
|
||||||
|
"size": 0.75rem,
|
||||||
|
"weight": 500,
|
||||||
|
"font-family": inherit,
|
||||||
|
"text-transform": uppercase,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
!default,
|
||||||
|
// 👉 Button
|
||||||
|
$button-border-radius: $border-radius-root !default,
|
||||||
|
$button-text-letter-spacing: 0 !default,
|
||||||
|
$button-text-transform: capitalize,
|
||||||
|
$button-elevation: (
|
||||||
|
"default": 0,
|
||||||
|
"hover": 4,
|
||||||
|
"active": 8,
|
||||||
|
)
|
||||||
|
!default,
|
||||||
|
|
||||||
|
// 👉 Tooltip
|
||||||
|
$tooltip-background-color: #212121 !default,
|
||||||
|
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
|
||||||
|
$tooltip-font-size: 0.75rem !default,
|
||||||
|
$tooltip-border-radius: 4px !default,
|
||||||
|
$tooltip-padding: 4px 8px !default,
|
||||||
|
|
||||||
|
// 👉 Rounded
|
||||||
|
$rounded: (
|
||||||
|
0: 0,
|
||||||
|
"sm": $border-radius-root * 0.5,
|
||||||
|
null: $border-radius-root,
|
||||||
|
"md": $border-radius-root * 1,
|
||||||
|
"lg": $border-radius-root * 2,
|
||||||
|
"xl": $border-radius-root * 6,
|
||||||
|
"pill": 9999px,
|
||||||
|
"circle": 50%,
|
||||||
|
),
|
||||||
|
|
||||||
|
// 👉 Card
|
||||||
|
// $card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
|
||||||
|
$card-elevation: 10 !default,
|
||||||
|
$card-title-line-height: 1.6 !default,
|
||||||
|
$card-text-padding: 24px !default,
|
||||||
|
$card-item-padding: 30px 30px 24px !default,
|
||||||
|
$card-actions-padding: 10px 24px 24px !default,
|
||||||
|
$card-subtitle-opacity: 1 !default // $card-border-color $border-color-root
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.single-line-alert {
|
||||||
|
.v-alert__close,
|
||||||
|
.v-alert__prepend {
|
||||||
|
align-self: center !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.single-line-alert {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.v-alert__append {
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
}
|
||||||
|
.v-alert__close {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.v-alert__content {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
.v-breadcrumbs{
|
||||||
|
.v-breadcrumbs-divider{
|
||||||
|
padding: 0 0 !important;
|
||||||
|
}
|
||||||
|
.v-breadcrumbs-item--link{
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.v-btn-group .v-btn {
|
||||||
|
height: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn-group {
|
||||||
|
border-color: rgb(var(--v-theme-borderColor)) !important;
|
||||||
|
}
|
||||||
|
.v-btn{
|
||||||
|
text-transform: capitalize;
|
||||||
|
letter-spacing: 0;
|
||||||
|
border-radius: 30px;
|
||||||
|
|
||||||
|
&.v-btn--variant-elevated{
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.v-btn--slim{
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.v-btn--elevated:hover{
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Outline Card
|
||||||
|
.v-card--variant-outlined {
|
||||||
|
border-color: rgba(var(--v-theme-borderColor)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card--variant-elevated,
|
||||||
|
.v-card--variant-flat {
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
||||||
|
&:hover {
|
||||||
|
scale: 1.01;
|
||||||
|
transition: all 0.1s ease-in 0s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card {
|
||||||
|
width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
.color-inherits {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
.v-responsive__content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.v-timeline-divider__before,.v-timeline-divider__after {
|
||||||
|
background: rgba(var(--v-border-color), 1);
|
||||||
|
}
|
||||||
|
.v-card-text{
|
||||||
|
padding: 24px 24px;
|
||||||
|
}
|
||||||
|
.v-card-item{
|
||||||
|
padding: 24px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme cards
|
||||||
|
.cardBordered {
|
||||||
|
.v-card {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-o-card{
|
||||||
|
.v-card-item{
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title{
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color:rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
.card-subtitle{
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color:rgb(var(--v-theme-textSecondary));
|
||||||
|
}
|
||||||
|
.dark-card-title{
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color:rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.theme-carousel .v-carousel__progress {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.v-pagination__item--is-active .v-btn__overlay {
|
||||||
|
opacity: 0.15 !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
.v-table {
|
||||||
|
|
||||||
|
&.datatabels {
|
||||||
|
|
||||||
|
&.productlist {
|
||||||
|
.v-data-table-header__content span {
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toolbar {
|
||||||
|
.v-input__control {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toolbar__content {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr th:first-child {
|
||||||
|
padding-left: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr td:first-child {
|
||||||
|
padding-left: 0px !important;
|
||||||
|
}
|
||||||
|
tbody tr td{
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-selection-control--dirty .v-selection-control__input>.v-icon {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width:1368px) {
|
||||||
|
|
||||||
|
.v-table {
|
||||||
|
|
||||||
|
&.datatabels {
|
||||||
|
|
||||||
|
&.productlist {
|
||||||
|
.v-data-table-header__content span {
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 14px 5px !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 15px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th {
|
||||||
|
padding: 14px 5px !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 15px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-pagination {
|
||||||
|
.v-pagination__list {
|
||||||
|
.v-pagination__item--is-active {
|
||||||
|
.v-btn {
|
||||||
|
.v-btn__overlay {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
background-color: rgb(var(--v-theme-grey100)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.v-expansion-panel-title__overlay{
|
||||||
|
background: rgba(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
.v-expansion-panel:not(:first-child)::after {
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@use "../variables" as *;
|
||||||
|
|
||||||
|
.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr,
|
||||||
|
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start {
|
||||||
|
border-radius: $border-radius-root 0 0 $border-radius-root;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr,
|
||||||
|
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end {
|
||||||
|
border-radius: 0 $border-radius-root $border-radius-root 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-field {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
// select outlined
|
||||||
|
.v-field--variant-outlined .v-field__outline__start,
|
||||||
|
.v-field--variant-outlined .v-field__outline__notch::before,
|
||||||
|
.v-field--variant-outlined .v-field__outline__notch::after,
|
||||||
|
.v-field--variant-outlined .v-field__outline__end {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.v-field--active .v-label.v-field-label{
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// variant
|
||||||
|
.v-input--density-default,
|
||||||
|
.v-field--variant-solo,
|
||||||
|
.v-field--variant-filled {
|
||||||
|
--v-input-control-height: 51px;
|
||||||
|
--v-input-padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// comfortable
|
||||||
|
.v-input--density-comfortable {
|
||||||
|
--v-input-control-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact
|
||||||
|
.v-input--density-compact {
|
||||||
|
--v-input-padding-top: 10px;
|
||||||
|
}
|
||||||
|
.v-label {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
.v-switch .v-label,
|
||||||
|
.v-checkbox .v-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-text-field__suffix {
|
||||||
|
opacity: 1;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-none .v-field--variant-solo {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.v-time-picker-clock{
|
||||||
|
background: rgb(var(--v-theme-grey100)) ;
|
||||||
|
}
|
||||||
|
.v-time-picker-controls__ampm__btn.v-btn.v-btn--density-default{
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
.v-stepper-header,.v-stepper.v-sheet{
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-time-picker-controls__time__btn.v-btn--density-default.v-btn {
|
||||||
|
width: 55px !important;
|
||||||
|
height: 55px !important;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.v-time-picker-controls__time__separator {
|
||||||
|
font-size: 36px !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.v-list.theme-list {
|
||||||
|
.v-list-item:hover > .v-list-item__overlay {
|
||||||
|
opacity: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.v-list-item--variant-text {
|
||||||
|
.v-list-item__overlay {
|
||||||
|
background: rgb(var(--v-theme-hoverColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-list-item__prepend,
|
||||||
|
.v-list-item__content {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-list-item__overlay {
|
||||||
|
background-color: rgb(var(--v-theme-hoverColor));
|
||||||
|
}
|
||||||
|
.v-list-item.v-list-item--active{
|
||||||
|
.v-list-item__overlay{
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-items{
|
||||||
|
min-height: 40px !important;
|
||||||
|
margin-bottom: 5px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-list-item-title{
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.v-navigation-drawer__scrim.fade-transition-leave-to {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// For checkbox & radios
|
||||||
|
.v-selection-control__input > .v-icon.mdi-checkbox-blank-outline,
|
||||||
|
.v-selection-control__input > .v-icon.mdi-radiobox-blank {
|
||||||
|
color: rgb(var(--v-theme-inputBorder));
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@use "../variables" as *;
|
||||||
|
|
||||||
|
.elevation-9 {
|
||||||
|
box-shadow: rgb(0 0 0 / 5%) 0px 9px 17.5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-10 {
|
||||||
|
box-shadow: $box-shadow !important;
|
||||||
|
}
|
||||||
|
.elevation-1 {
|
||||||
|
box-shadow:0px 12px 30px -2px rgba(58,75,116,0.14) !important
|
||||||
|
}
|
||||||
|
.elevation-2 {
|
||||||
|
box-shadow:0px 24px 24px -12px rgba(0, 0, 0, .05) !important
|
||||||
|
}
|
||||||
|
.elevation-3 {
|
||||||
|
box-shadow: rgba(145,158,171,0.2) 0px 0px 2px 0px, rgba(145,158,171,0.12) 0px 12px 24px -4px !important;
|
||||||
|
}
|
||||||
|
.elevation-4{
|
||||||
|
box-shadow: 0px 12px 12px -6px rgba(0,0,0,0.15) !important;
|
||||||
|
}
|
||||||
|
.elevation-5
|
||||||
|
{
|
||||||
|
box-shadow: 1px 0 7px rgba(0, 0, 0, .05)!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-shadow {
|
||||||
|
box-shadow: rgba(var(--v-theme-primary), 0.30) 0px 12px 14px 0px;
|
||||||
|
&:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.v-stepper-item--selected .v-stepper-item__avatar.v-avatar, .v-stepper-item--complete .v-stepper-item__avatar.v-avatar {
|
||||||
|
background: rgb(var(--v-theme-primary)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-stepper-item__avatar.v-avatar {
|
||||||
|
background: rgba(var(--v-theme-primary), var(--v-medium-emphasis-opacity)) !important;
|
||||||
|
color: rgb(var(--v-theme-on-primary)) !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.v-selection-control.v-selection-control--density-default {
|
||||||
|
.v-switch__track,
|
||||||
|
.v-switch__thumb {
|
||||||
|
background-color: rgb(var(--v-theme-grey200));
|
||||||
|
}
|
||||||
|
&.v-selection-control--dirty {
|
||||||
|
.v-selection-control__wrapper.text-primary {
|
||||||
|
.v-switch__track {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.6);
|
||||||
|
}
|
||||||
|
.v-switch__thumb {
|
||||||
|
background-color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.v-selection-control__wrapper.text-secondary {
|
||||||
|
.v-switch__track {
|
||||||
|
background-color: rgba(var(--v-theme-secondary), 0.6);
|
||||||
|
}
|
||||||
|
.v-switch__thumb {
|
||||||
|
background-color: rgb(var(--v-theme-secondary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.v-selection-control__wrapper.text-warning {
|
||||||
|
.v-switch__track {
|
||||||
|
background-color: rgba(var(--v-theme-warning), 0.6);
|
||||||
|
}
|
||||||
|
.v-switch__thumb {
|
||||||
|
background-color: rgb(var(--v-theme-warning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.v-selection-control__wrapper.text-error {
|
||||||
|
.v-switch__track {
|
||||||
|
background-color: rgba(var(--v-theme-error), 0.6);
|
||||||
|
}
|
||||||
|
.v-switch__thumb {
|
||||||
|
background-color: rgb(var(--v-theme-error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.v-selection-control__wrapper.text-success {
|
||||||
|
.v-switch__track {
|
||||||
|
background-color: rgba(var(--v-theme-success), 0.6);
|
||||||
|
}
|
||||||
|
.v-switch__thumb {
|
||||||
|
background-color: rgb(var(--v-theme-success));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > td,
|
||||||
|
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > th,
|
||||||
|
.v-table .v-table__wrapper > table > thead > tr:last-child > th {
|
||||||
|
border-bottom: thin solid rgba(var(--v-border-color)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table{
|
||||||
|
th.v-data-table__th{
|
||||||
|
font-size:16px;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
}
|
||||||
|
td.v-data-table__td{
|
||||||
|
font-size: 14px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.v-data-table-footer{
|
||||||
|
padding: 15px 8px;
|
||||||
|
}
|
||||||
|
.v-data-table-header__sort-badge{
|
||||||
|
background-color:rgb(var(--v-theme-borderColor)) !important;
|
||||||
|
}
|
||||||
|
.tdhead{
|
||||||
|
font-size:16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width:767px) {
|
||||||
|
.v-data-table-footer{
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table {
|
||||||
|
|
||||||
|
|
||||||
|
&.ticket-table {
|
||||||
|
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
th {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 16px 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&.invoice-table {
|
||||||
|
.v-table__wrapper {
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
th {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
padding: 0px 24px !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 24px !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@use "../variables" as *;
|
||||||
|
|
||||||
|
.theme-tab {
|
||||||
|
&.v-tabs {
|
||||||
|
.v-tab {
|
||||||
|
border-radius: $border-radius-root !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
&.v-slide-group-item--active {
|
||||||
|
background: rgb(var(--v-theme-primary));
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.v-text-field input {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.v-field__outline {
|
||||||
|
color: rgb(var(--v-theme-inputBorder));
|
||||||
|
--v-field-border-opacity: 1 !important;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
.v-field--variant-outlined {
|
||||||
|
background-color: rgba(0, 0, 0, 0.025);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.v-textarea input {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
}
|
||||||