first commit

This commit is contained in:
2026-06-19 23:55:45 -07:00
commit f2e4730549
297 changed files with 30726 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11
+43
View File
@@ -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: "^_",
},
],
},
}
+30
View File
@@ -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
+9
View File
@@ -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
+18
View File
@@ -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.
+11
View File
@@ -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"]
+37
View File
@@ -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>
+6003
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+8
View File
@@ -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

+113
View File
@@ -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>
+12
View File
@@ -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
+44
View File
@@ -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
+23
View File
@@ -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
+46
View File
@@ -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
+18
View File
@@ -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
+81
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

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>
+46
View File
@@ -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>
+50
View File
@@ -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>
+82
View File
@@ -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>
+17
View File
@@ -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>
+160
View File
@@ -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>
+59
View File
@@ -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>
+47
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import { visible } from "./visible"
export default {
visible,
}
+27
View File
@@ -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
+49
View File
@@ -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>
+22
View File
@@ -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>
+69
View File
@@ -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>
+33
View File
@@ -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>
+50
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
export default {
user: {
roles: {
system_admin: "System Admin",
user: "User",
},
},
}
+25
View File
@@ -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",
})
+23
View File
@@ -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>
+16
View File
@@ -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>
+27
View File
@@ -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>
+115
View File
@@ -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>
+93
View File
@@ -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">&nbsp;</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>
+79
View File
@@ -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>
+79
View File
@@ -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>
+79
View File
@@ -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>
+46
View File
@@ -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>
+14
View File
@@ -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",
})
+13
View File
@@ -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,
},
})
+86
View File
@@ -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",
},
},
})
+152
View File
@@ -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
+50
View File
@@ -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
+204
View File
@@ -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;
}
+150
View File
@@ -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
);
+24
View File
@@ -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;
}
}
+22
View File
@@ -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;
}
+69
View File
@@ -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));
}
+3
View File
@@ -0,0 +1,3 @@
.theme-carousel .v-carousel__progress {
position: absolute;
}
+3
View File
@@ -0,0 +1,3 @@
.v-pagination__item--is-active .v-btn__overlay {
opacity: 0.15 !important;
}
+99
View File
@@ -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;
}
+29
View File
@@ -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));
}
+35
View File
@@ -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;
}
+18
View File
@@ -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;
}
+34
View File
@@ -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;
}
+33
View File
@@ -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;
}
}
+8
View File
@@ -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;
}
+48
View File
@@ -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));
}
}
}
}
+99
View File
@@ -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;
}
}
}
}
}
}
}
}
+14
View File
@@ -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));
}
}
}
}
+13
View File
@@ -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);
}
}
+7
View File
@@ -0,0 +1,7 @@
.v-textarea input {
font-size: 0.875rem;
font-weight: 500;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
}

Some files were not shown because too many files have changed in this diff Show More