Initial commit

This commit is contained in:
2026-06-24 23:47:55 -07:00
commit d134b480a0
297 changed files with 30726 additions and 0 deletions
@@ -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>