generated from alphane/template
Initial commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<!-- Special module scope required for exports -->
|
||||
<script lang="ts">
|
||||
import { RouteLocationRaw } from "vue-router"
|
||||
|
||||
type Breadcrumb = {
|
||||
title: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
export { type Breadcrumb }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
breadcrumbs: Breadcrumb[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-3 mb-6">
|
||||
<div class="d-flex justify-space-between">
|
||||
<div class="d-flex py-0 align-center">
|
||||
<div>
|
||||
<h2 class="text-h3 mb-2">{{ props.title }}</h2>
|
||||
<v-breadcrumbs
|
||||
:items="props.breadcrumbs"
|
||||
class="text-h6 font-weight-regular pa-0 ml-n1"
|
||||
>
|
||||
<template
|
||||
v-if="props.breadcrumbs"
|
||||
#divider
|
||||
>
|
||||
<v-icon>mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
<template #title="{ item }">
|
||||
<h6 class="text-medium-emphasis text-subtitle-1">{{ item.title }}</h6>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<slot name="append" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.page-breadcrumb {
|
||||
.v-toolbar {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<v-card
|
||||
class="mb-5 app-card"
|
||||
:to="to"
|
||||
>
|
||||
<v-progress-linear
|
||||
v-if="mainColor && !isNil(progressValue)"
|
||||
:model-value="progressValue"
|
||||
:color="mainColor"
|
||||
height="16"
|
||||
>
|
||||
<span style="font-size: 12px"> {{ progressValue }}% Complete</span>
|
||||
</v-progress-linear>
|
||||
<v-card-title
|
||||
v-if="title"
|
||||
class="d-flex"
|
||||
:class="{ 'mb-n3': subtitle }"
|
||||
>
|
||||
{{ title }}
|
||||
<v-spacer />
|
||||
<slot name="append-title"></slot>
|
||||
</v-card-title>
|
||||
<v-card-subtitle
|
||||
v-if="subtitle"
|
||||
:class="{ 'mt-3': !title }"
|
||||
style="font-weight: 500 !important"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</v-card-subtitle>
|
||||
<v-card-text :class="{ 'mt-n5': subtitle }">
|
||||
<slot></slot>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isNil } from "lodash"
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
to?: string | { name: string; params?: Record<string, string | number> }
|
||||
mainColor?: string
|
||||
progressValue?: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="logo">
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="d-flex"
|
||||
>
|
||||
<img
|
||||
class="ml-0 mt-1"
|
||||
style="height: 36px; transform: rotate(-12deg)"
|
||||
:src="AppLogo"
|
||||
/>
|
||||
<div v-if="sidebarMini || mdAndDown"></div>
|
||||
<div
|
||||
v-else
|
||||
class="d-flex"
|
||||
style="width: 200px"
|
||||
>
|
||||
<div
|
||||
class="mt-1 ml-3 rotyr-font"
|
||||
style="font-size: 26px; color: #505682"
|
||||
>
|
||||
ALPHANE
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppLogo from "@/assets/app_logo_small.png"
|
||||
|
||||
import { useDisplay } from "vuetify"
|
||||
import useInterface from "@/use/use-interface"
|
||||
import { watch } from "vue"
|
||||
|
||||
const { sidebarMini, setSidebarMini } = useInterface()
|
||||
const { mdAndDown } = useDisplay()
|
||||
|
||||
watch(mdAndDown, (newVal) => {
|
||||
if (newVal === true) setSidebarMini(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.logo a {
|
||||
text-decoration: none !important;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<v-snackbar
|
||||
v-model="showSnackbar"
|
||||
v-bind="{
|
||||
multiLine: true,
|
||||
timeout: defaultTimeout,
|
||||
...options,
|
||||
}"
|
||||
>
|
||||
<span :class="`text-${constrastingColor}`">
|
||||
{{ message }}
|
||||
</span>
|
||||
<template #actions>
|
||||
<v-btn
|
||||
:color="constrastingColor"
|
||||
variant="text"
|
||||
@click="close"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue"
|
||||
|
||||
import { useSnack } from "@/use/use-snack"
|
||||
import { isEmpty } from "lodash"
|
||||
|
||||
const { message, options, reset } = useSnack()
|
||||
|
||||
const showSnackbar = ref(false)
|
||||
const defaultTimeout = 4000
|
||||
|
||||
const constrastingColor = computed(() => getContrastingColor(options.value.color))
|
||||
|
||||
watch(
|
||||
() => [message.value, options.value],
|
||||
() => {
|
||||
if (isEmpty(message.value)) return
|
||||
|
||||
show()
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => showSnackbar.value,
|
||||
(newShowSnackbar) => {
|
||||
if (newShowSnackbar === false) {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function close() {
|
||||
showSnackbar.value = false
|
||||
}
|
||||
|
||||
function show() {
|
||||
showSnackbar.value = true
|
||||
}
|
||||
|
||||
function getContrastingColor(color: string | undefined) {
|
||||
if (color === undefined) return "white"
|
||||
|
||||
const colorMap: {
|
||||
[key: string]: "white" | "black" | undefined
|
||||
} = {
|
||||
primary: "white",
|
||||
secondary: "black",
|
||||
accent: "black",
|
||||
error: "white",
|
||||
info: "white",
|
||||
success: "white",
|
||||
warning: "black",
|
||||
}
|
||||
|
||||
return colorMap[color] || "white"
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useSlots } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const slots = useSlots()
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="mb-5 savable-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex">
|
||||
<div
|
||||
class="d-flex cursor-pointer"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<v-icon
|
||||
:icon="collapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'"
|
||||
color="primary"
|
||||
class="mr-4 mt-1"
|
||||
/>
|
||||
|
||||
<h2 class="text-h5 title mb-1">{{ props.title }}</h2>
|
||||
</div>
|
||||
<v-spacer v-if="slots.rightpart && !collapsed" />
|
||||
<div
|
||||
v-if="slots.rightpart && !collapsed"
|
||||
class="float-right"
|
||||
>
|
||||
<!---Toggle Button For mobile-->
|
||||
<slot name="rightpart"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-slide-y-transition>
|
||||
<div v-show="!collapsed">
|
||||
<v-divider class="my-3" />
|
||||
<slot></slot>
|
||||
</div>
|
||||
</v-slide-y-transition>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="internalDialog"
|
||||
max-width="500"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<slot>{{ message }}</slot>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:color="confirmColor"
|
||||
variant="flat"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
message?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
confirmColor?: string
|
||||
}>(),
|
||||
{
|
||||
title: "Confirm",
|
||||
message: "Are you sure?",
|
||||
confirmText: "Confirm",
|
||||
cancelText: "Cancel",
|
||||
confirmColor: "primary",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean]
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const internalDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
})
|
||||
|
||||
function confirm() {
|
||||
emit("confirm")
|
||||
internalDialog.value = false
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit("cancel")
|
||||
internalDialog.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<!-- Vertical layout -->
|
||||
<div v-if="vertical">
|
||||
<dt class="d-flex align-center font-weight-medium">
|
||||
<v-icon
|
||||
v-if="icon"
|
||||
size="18"
|
||||
class="mr-2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<span>{{ label }}</span>
|
||||
</dt>
|
||||
<dd class="mt-2">
|
||||
<v-progress-circular
|
||||
v-if="loading"
|
||||
indeterminate
|
||||
size="16"
|
||||
width="1"
|
||||
/>
|
||||
<slot>{{ modelValue }}</slot>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal layout -->
|
||||
<div
|
||||
v-else
|
||||
class="d-flex align-center gap-2"
|
||||
>
|
||||
<dt class="d-flex align-center font-weight-medium">
|
||||
<v-icon
|
||||
v-if="icon"
|
||||
size="18"
|
||||
class="mr-2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<span>{{ label }}:</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<v-progress-circular
|
||||
v-if="loading"
|
||||
indeterminate
|
||||
size="16"
|
||||
width="1"
|
||||
/>
|
||||
<slot>{{ modelValue }}</slot>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
icon?: string
|
||||
modelValue?: string | number | boolean | null | undefined
|
||||
vertical?: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
icon: "",
|
||||
modelValue: null,
|
||||
vertical: false,
|
||||
loading: false,
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gap-2 {
|
||||
gap: 0.5rem; /* 8px */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<v-card :elevation="elevation">
|
||||
<HeaderActionsCardBody
|
||||
:title="title"
|
||||
:header-tag="headerTag"
|
||||
:header-class="headerClass"
|
||||
:divider-class="dividerClass"
|
||||
:hide-body="hideBody"
|
||||
:body-class="bodyClass"
|
||||
:header-icon="headerIcon"
|
||||
>
|
||||
<template #header>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions"></slot>
|
||||
</template>
|
||||
<template #default>
|
||||
<slot></slot>
|
||||
</template>
|
||||
<template
|
||||
v-if="$slots.actions"
|
||||
#actions
|
||||
>
|
||||
<slot name="actions"></slot>
|
||||
</template>
|
||||
<!--
|
||||
TODO: replace current #actions slot with #actions-next code.
|
||||
-->
|
||||
<template
|
||||
v-if="$slots['actions-next']"
|
||||
#actions-next
|
||||
>
|
||||
<slot name="actions-next"></slot>
|
||||
</template>
|
||||
</HeaderActionsCardBody>
|
||||
<slot name="content"></slot>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type VCard } from "vuetify/components"
|
||||
|
||||
import { type VueHtmlClass } from "@/utils/utility-types"
|
||||
|
||||
import HeaderActionsCardBody from "@/components/common/HeaderActionsCardBody.vue"
|
||||
|
||||
/**
|
||||
* Keep in sync with web/src/components/common/HeaderActionsCardBody.vue
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"
|
||||
headerClass?: VueHtmlClass
|
||||
dividerClass?: VueHtmlClass
|
||||
elevation?: VCard["elevation"]
|
||||
hideBody?: boolean
|
||||
bodyClass?: VueHtmlClass
|
||||
headerIcon?: string
|
||||
}>(),
|
||||
{
|
||||
title: "",
|
||||
headerTag: "h3",
|
||||
headerClass: "text-h5",
|
||||
dividerClass: "mb-3",
|
||||
elevation: 0,
|
||||
hideBody: false,
|
||||
bodyClass: "",
|
||||
headerIcon: "",
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<v-card-title class="d-flex flex-sm-row justify-sm-space-between align-sm-end">
|
||||
<slot name="header">
|
||||
<component
|
||||
:is="headerTag"
|
||||
v-if="!isEmpty(title)"
|
||||
:class="headerClass"
|
||||
class="py-2"
|
||||
>
|
||||
<v-icon
|
||||
v-if="headerIcon"
|
||||
class="mr-2"
|
||||
>{{ headerIcon }}</v-icon
|
||||
>
|
||||
{{ title }}
|
||||
</component>
|
||||
</slot>
|
||||
<v-spacer class="mt-4 mt-md-0" />
|
||||
<slot name="header-actions"></slot>
|
||||
</v-card-title>
|
||||
<v-divider
|
||||
v-if="!isEmpty(title)"
|
||||
:class="dividerClass"
|
||||
/>
|
||||
<v-card-text
|
||||
v-if="!hideBody"
|
||||
:class="bodyClass"
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="mt-4"
|
||||
>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<!--
|
||||
TODO: replace current #actions slot with #actions-next code.
|
||||
-->
|
||||
<v-card-actions
|
||||
v-if="$slots['actions-next']"
|
||||
class="d-flex flex-column flex-md-row"
|
||||
>
|
||||
<slot name="actions-next"></slot>
|
||||
</v-card-actions>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isEmpty } from "lodash"
|
||||
|
||||
import { type VueHtmlClass } from "@/utils/utility-types"
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"
|
||||
headerClass?: VueHtmlClass
|
||||
dividerClass?: VueHtmlClass
|
||||
hideBody?: boolean
|
||||
bodyClass?: VueHtmlClass
|
||||
headerIcon?: string
|
||||
}>(),
|
||||
{
|
||||
title: "",
|
||||
headerTag: "h3",
|
||||
headerClass: "text-h5",
|
||||
dividerClass: "mb-3",
|
||||
hideBody: false,
|
||||
bodyClass: "",
|
||||
headerIcon: "",
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<v-card :elevation="elevation">
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="isValid"
|
||||
@submit="emit('submit', $event)"
|
||||
>
|
||||
<HeaderActionsCardBody
|
||||
:title="title"
|
||||
:header-tag="headerTag"
|
||||
:header-class="headerClass"
|
||||
:divider-class="dividerClass"
|
||||
:body-class="bodyClass"
|
||||
>
|
||||
<template #header>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions"></slot>
|
||||
</template>
|
||||
<template #default>
|
||||
<slot></slot>
|
||||
</template>
|
||||
<template
|
||||
v-if="$slots.actions"
|
||||
#actions
|
||||
>
|
||||
<slot name="actions"></slot>
|
||||
</template>
|
||||
<!--
|
||||
TODO: replace current #actions slot with #actions-next code.
|
||||
-->
|
||||
<template
|
||||
v-if="$slots['actions-next']"
|
||||
#actions-next
|
||||
>
|
||||
<slot name="actions-next"></slot>
|
||||
</template>
|
||||
</HeaderActionsCardBody>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import { type VCard, type VForm } from "vuetify/components"
|
||||
import { type SubmitEventPromise } from "vuetify/lib/composables/form"
|
||||
|
||||
import { type VueHtmlClass } from "@/utils/utility-types"
|
||||
|
||||
import HeaderActionsCardBody from "@/components/common/HeaderActionsCardBody.vue"
|
||||
|
||||
const isValid = defineModel<boolean | null>({ default: null })
|
||||
|
||||
/**
|
||||
* Keep in sync with web/src/components/common/HeaderActionsCardBody.vue
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
||||
headerClass?: VueHtmlClass
|
||||
dividerClass?: VueHtmlClass
|
||||
bodyClass?: VueHtmlClass
|
||||
elevation?: VCard["elevation"]
|
||||
}>(),
|
||||
{
|
||||
title: "",
|
||||
headerTag: "h3",
|
||||
headerClass: "text-h5",
|
||||
dividerClass: "mb-3",
|
||||
bodyClass: "",
|
||||
elevation: 0,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "submit", value: SubmitEventPromise): void
|
||||
}>()
|
||||
|
||||
const form = ref<InstanceType<typeof VForm> | null>(null)
|
||||
|
||||
async function validate() {
|
||||
if (isNil(form.value)) throw new Error("form component not loaded")
|
||||
|
||||
return form.value?.validate()
|
||||
}
|
||||
|
||||
async function resetValidation() {
|
||||
if (isNil(form.value)) throw new Error("form component not loaded")
|
||||
|
||||
return form.value?.resetValidation()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate,
|
||||
resetValidation,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="d-flex justify-center align-center h-screen">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="64"
|
||||
class="mb-5"
|
||||
color="#505682"
|
||||
/>
|
||||
<h1 class="text-center">{{ message }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
withDefaults(defineProps<{ message: string }>(), { message: "ALPAHNE" })
|
||||
</script>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<v-text-field
|
||||
:model-value="modelValue"
|
||||
:label="label"
|
||||
:rules="combinedRules"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:clearable="clearable"
|
||||
:hint="hint"
|
||||
:persistent-hint="persistentHint"
|
||||
type="number"
|
||||
suffix="%"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
@update:model-value="updateModelValue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { VTextField } from "vuetify/components"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: number | null | undefined
|
||||
label?: string
|
||||
rules?: VTextField["rules"]
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
clearable?: boolean
|
||||
hint?: string
|
||||
persistentHint?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: "Percentage",
|
||||
rules: () => [],
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
clearable: false,
|
||||
persistentHint: false,
|
||||
hint: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: number | null]
|
||||
}>()
|
||||
|
||||
const combinedRules = computed(() => {
|
||||
const baseRules = [
|
||||
(value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return true
|
||||
return true
|
||||
},
|
||||
(value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return true
|
||||
const numValue = Number(value)
|
||||
if (isNaN(numValue)) return "Must be a valid number"
|
||||
if (numValue < 0) return "Percentage must be at least 0"
|
||||
if (numValue > 100) return "Percentage must be at most 100"
|
||||
return true
|
||||
},
|
||||
]
|
||||
|
||||
return [...baseRules, ...(props.rules || [])]
|
||||
})
|
||||
|
||||
function updateModelValue(value: string | number | null) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
emit("update:modelValue", null)
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = Number(value)
|
||||
if (isNaN(numValue)) {
|
||||
emit("update:modelValue", null)
|
||||
return
|
||||
}
|
||||
|
||||
// Clamp value between 0 and 100
|
||||
const clampedValue = Math.min(Math.max(numValue, 0), 100)
|
||||
emit("update:modelValue", clampedValue)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="internalDialog"
|
||||
:max-width="mobile ? undefined : maxWidth"
|
||||
:fullscreen="mobile"
|
||||
:persistent="persistent"
|
||||
scrollable
|
||||
>
|
||||
<HeaderActionsCard>
|
||||
<template #header>
|
||||
<div
|
||||
class="d-flex align-start ga-2 w-100"
|
||||
:class="{ 'pl-4': mobile, 'pt-2': mobile }"
|
||||
style="white-space: normal"
|
||||
>
|
||||
<slot name="title">
|
||||
<h3
|
||||
class="text-h5 py-2 flex-grow-1 text-break"
|
||||
style="min-width: 0; overflow-wrap: anywhere; white-space: normal"
|
||||
>
|
||||
<v-icon
|
||||
v-if="titleIcon"
|
||||
class="mr-2"
|
||||
>{{ titleIcon }}</v-icon
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
</slot>
|
||||
<v-btn
|
||||
v-if="!hideClose"
|
||||
icon="mdi-close"
|
||||
:variant="mobile ? 'tonal' : 'text'"
|
||||
density="comfortable"
|
||||
class="flex-shrink-0"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<slot />
|
||||
</HeaderActionsCard>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { useDisplay } from "vuetify"
|
||||
|
||||
import HeaderActionsCard from "@/components/shared/cards/HeaderActionsCard.vue"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
titleIcon?: string
|
||||
maxWidth?: number | string
|
||||
persistent?: boolean
|
||||
hideClose?: boolean
|
||||
}>(),
|
||||
{
|
||||
title: "",
|
||||
titleIcon: "",
|
||||
maxWidth: 700,
|
||||
persistent: false,
|
||||
hideClose: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean]
|
||||
}>()
|
||||
|
||||
const { mobile } = useDisplay()
|
||||
|
||||
const internalDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit("update:modelValue", value),
|
||||
})
|
||||
|
||||
function close() {
|
||||
internalDialog.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<v-date-input
|
||||
v-model="date"
|
||||
v-bind="$attrs"
|
||||
@update:model-value="emitStringResult"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { DateTime } from "luxon"
|
||||
import { isNil } from "lodash"
|
||||
|
||||
/**
|
||||
* A date input accepts and returns a string.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string | null | undefined
|
||||
returnFormat?: string
|
||||
}>(),
|
||||
{
|
||||
returnFormat: "yyyy-MM-dd",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string | null]
|
||||
}>()
|
||||
|
||||
const date = ref<Date | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (isNil(newValue)) {
|
||||
date.value = null
|
||||
} else {
|
||||
const dateTime = DateTime.fromISO(newValue)
|
||||
if (dateTime.isValid) {
|
||||
date.value = dateTime.toJSDate()
|
||||
} else {
|
||||
date.value = null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* NOTE: v-date-input returns Date | string, rather than just "string" like it's types imply.
|
||||
*/
|
||||
function emitStringResult(value: unknown) {
|
||||
if (value instanceof Date) {
|
||||
const dateTime = DateTime.fromJSDate(value)
|
||||
if (dateTime.isValid) {
|
||||
const stringValue = dateTime.toFormat(props.returnFormat)
|
||||
emit("update:modelValue", stringValue)
|
||||
} else {
|
||||
emit("update:modelValue", null)
|
||||
}
|
||||
} else if (typeof value === "string") {
|
||||
emit("update:modelValue", value)
|
||||
} else {
|
||||
emit("update:modelValue", null)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<v-app-bar
|
||||
id="top"
|
||||
elevation="10"
|
||||
height="60"
|
||||
class="main-head pl-2"
|
||||
>
|
||||
<div class="mr-3">
|
||||
<AppLogo />
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn
|
||||
v-if="isSystemAdmin"
|
||||
icon
|
||||
variant="text"
|
||||
class="custom-hover-primary mr-2"
|
||||
size="small"
|
||||
title="AI Assistant"
|
||||
@click="$emit('toggle-ai-panel')"
|
||||
>
|
||||
<v-icon size="28">mdi-robot-outline</v-icon>
|
||||
</v-btn>
|
||||
<ProfileMenu />
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useCurrentUser from "@/use/use-current-user"
|
||||
|
||||
import AppLogo from "@/components/common/AppLogo.vue"
|
||||
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
|
||||
|
||||
defineEmits<{
|
||||
"toggle-ai-panel": []
|
||||
}>()
|
||||
|
||||
const { isSystemAdmin } = useCurrentUser<true>()
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
v-model="sidebarDrawer"
|
||||
left
|
||||
elevation="0"
|
||||
rail-width="75"
|
||||
app
|
||||
class="leftSidebar"
|
||||
:rail="sidebarMini"
|
||||
expand-on-hover
|
||||
width="256"
|
||||
>
|
||||
<div class="pa-2">
|
||||
<v-card
|
||||
v-if="currentUser"
|
||||
variant="outlined"
|
||||
style="overflow: hidden"
|
||||
>
|
||||
<v-card-text
|
||||
v-if="!sidebarMini"
|
||||
class="pa-2"
|
||||
>
|
||||
<div class="font-weight-bold">{{ currentUser.displayName }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-list class="d-none py-3 px-4">
|
||||
<v-list-subheader
|
||||
class="text-uppercase text-13 font-weight-semibold textPrimary d-flex align-items-center"
|
||||
>
|
||||
<span class="mini-icon">
|
||||
<IconDotsCircleHorizontal
|
||||
size="16"
|
||||
stroke-width="1.5"
|
||||
class="iconClass"
|
||||
/>
|
||||
</span>
|
||||
<span class="mini-text">Aircraft</span>
|
||||
</v-list-subheader>
|
||||
|
||||
<v-list-item
|
||||
v-scroll-to="{ el: '#top' }"
|
||||
:to="{}"
|
||||
rounded="pill"
|
||||
class="mb-1 px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<IconPointFilled
|
||||
size="24"
|
||||
class="dot mini-icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span class="mini-text"> Y-CCAA</span>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconDotsCircleHorizontal, IconPointFilled } from "@tabler/icons-vue"
|
||||
|
||||
import useCurrentUser from "@/use/use-current-user"
|
||||
import useInterface from "@/use/use-interface"
|
||||
|
||||
const { sidebarDrawer, sidebarMini } = useInterface()
|
||||
|
||||
const { currentUser } = useCurrentUser()
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<v-breadcrumbs
|
||||
class="flex-wrap"
|
||||
:items="breadcrumbsWithExactTrueByDefault"
|
||||
color=""
|
||||
>
|
||||
<template #title="{ item }">
|
||||
<transition
|
||||
name="breadcrumb-title-fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="isEmpty(item.title)"
|
||||
key="title-loader"
|
||||
size="16"
|
||||
color="secondary"
|
||||
width="1"
|
||||
indeterminate
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="title-text"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</transition>
|
||||
</template>
|
||||
<template #divider>
|
||||
<v-icon>mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export { type Breadcrumb } from "@/use/use-breadcrumbs"
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { isEmpty } from "lodash"
|
||||
|
||||
import { type Breadcrumb } from "@/use/use-breadcrumbs"
|
||||
|
||||
const props = defineProps<{
|
||||
items: Breadcrumb[]
|
||||
}>()
|
||||
|
||||
// Changes https://vuetifyjs.com/en/components/breadcrumbs/ default behavior.
|
||||
// By default v-breadcrumbs will disable all crumbs up to the current page in a nested paths.
|
||||
// You can prevent this behavior by using exact: true on each applicable breadcrumb in the items array.
|
||||
const breadcrumbsWithExactTrueByDefault = computed(() =>
|
||||
props.items.map((item) => ({
|
||||
...item,
|
||||
title: item.title ?? "",
|
||||
exact: item.exact ?? true,
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-title-fade-enter-active,
|
||||
.breadcrumb-title-fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.breadcrumb-title-fade-enter-from,
|
||||
.breadcrumb-title-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="showMenu"
|
||||
:close-on-content-click="false"
|
||||
class="profile_popup"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
variant="tonal"
|
||||
v-bind="props"
|
||||
icon
|
||||
:text="currentUserInitials"
|
||||
@click="showMenu = true"
|
||||
/>
|
||||
</template>
|
||||
<v-sheet
|
||||
rounded="md"
|
||||
width="360"
|
||||
elevation="11"
|
||||
>
|
||||
<div class="px-8 pt-3">
|
||||
<div class="d-flex align-center mt-4 pb-6">
|
||||
<div>
|
||||
<h6 class="text-h6 mb-n1">{{ currentUser.displayName }}</h6>
|
||||
<div class="d-flex align-center mt-2">
|
||||
<IconMail
|
||||
:size="18"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
|
||||
<span class="text-subtitle-1 font-weight-regular textSecondary ml-2">{{
|
||||
currentUser.email
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
</div>
|
||||
|
||||
<v-list
|
||||
class="py-0 theme-list"
|
||||
lines="two"
|
||||
>
|
||||
<v-list-item
|
||||
class="py-4 px-8 custom-text-primary"
|
||||
:to="{
|
||||
name: 'ProfilePage',
|
||||
}"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar color="info">
|
||||
<IconUserCircle></IconUserCircle>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<div>
|
||||
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">My Profile</h6>
|
||||
</div>
|
||||
<p class="text-subtitle-1 font-weight-regular textSecondary">Manage your information</p>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="isSystemAdmin"
|
||||
class="py-4 px-8 custom-text-primary"
|
||||
:to="{
|
||||
name: 'administration/AdministrationDashboardPage',
|
||||
}"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar color="warning">
|
||||
<IconSettings></IconSettings>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<div>
|
||||
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">
|
||||
ROTYR System Administration
|
||||
</h6>
|
||||
</div>
|
||||
<p class="text-subtitle-1 font-weight-regular textSecondary">Manage this application</p>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="isSystemAdmin"
|
||||
class="py-4 px-8 custom-text-primary"
|
||||
:to="{
|
||||
name: 'StatusPage',
|
||||
}"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar color="secondary">
|
||||
<IconClock />
|
||||
</v-avatar>
|
||||
</template>
|
||||
<div>
|
||||
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">Version</h6>
|
||||
</div>
|
||||
<p class="text-subtitle-1 font-weight-regular textSecondary">
|
||||
{{ releaseTag || "2024.08.29" }}
|
||||
</p>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div class="pt-4 pb-6 px-8 text-center">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
block
|
||||
@click="signOut"
|
||||
>Logout</v-btn
|
||||
>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { IconClock, IconMail, IconSettings, IconUserCircle } from "@tabler/icons-vue"
|
||||
import { useAuth0 } from "@auth0/auth0-vue"
|
||||
import { isEmpty, isNil } from "lodash"
|
||||
|
||||
import useCurrentUser from "@/use/use-current-user"
|
||||
import useStatus from "@/use/use-status"
|
||||
|
||||
const { logout } = useAuth0()
|
||||
const { currentUser, isSystemAdmin, reset: resetCurrentUser } = useCurrentUser<true>()
|
||||
const showMenu = ref(false)
|
||||
|
||||
const { releaseTag } = useStatus()
|
||||
|
||||
const currentUserInitials = computed(() => {
|
||||
if (isNil(currentUser.value)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const { firstName, lastName, email } = currentUser.value
|
||||
if (!isNil(firstName) && !isEmpty(firstName) && !isNil(lastName) && !isEmpty(lastName)) {
|
||||
const initials = [firstName, lastName].map((name) => name[0]?.toUpperCase()).join("")
|
||||
return initials
|
||||
}
|
||||
|
||||
const initials = email
|
||||
.split(".")
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("")
|
||||
return initials
|
||||
})
|
||||
|
||||
function closeMenu() {
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
resetCurrentUser()
|
||||
|
||||
const returnTo = encodeURI(window.location.origin)
|
||||
return logout({ logoutParams: { returnTo } })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="showMenu"
|
||||
:close-on-content-click="false"
|
||||
class="search_popup"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="custom-hover-primary mr-2"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
>
|
||||
<IconSearch :size="26" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-sheet
|
||||
width="360"
|
||||
elevation="11"
|
||||
rounded="md"
|
||||
>
|
||||
<v-form class="d-flex flex-column pa-5">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
placeholder="Search"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
@click:clear="search = ''"
|
||||
/>
|
||||
<v-btn
|
||||
class="mt-2"
|
||||
color="primary"
|
||||
type="submit"
|
||||
block
|
||||
>
|
||||
<v-icon start>mdi-magnify</v-icon> Search
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<v-divider />
|
||||
<h5 class="text-h5 mt-4 px-5 pb-4">Recently Viewed</h5>
|
||||
<v-divider />
|
||||
</v-sheet>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
|
||||
import { IconSearch } from "@tabler/icons-vue"
|
||||
|
||||
const showMenu = ref(false)
|
||||
|
||||
const search = ref("")
|
||||
</script>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<v-skeleton-loader
|
||||
v-if="isNil(user)"
|
||||
type="card"
|
||||
/>
|
||||
<HeaderActionsFormCard
|
||||
v-else
|
||||
ref="headerActionsFormCard"
|
||||
title="User Details"
|
||||
elevation="10"
|
||||
@submit.prevent="saveWrapper"
|
||||
>
|
||||
<template #header-actions> </template>
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-label class="mb-2">First name *</v-label>
|
||||
<v-text-field
|
||||
v-model="user.firstName"
|
||||
hide-details="auto"
|
||||
:rules="[required]"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-label class="mb-2">Last name *</v-label>
|
||||
<v-text-field
|
||||
v-model="user.lastName"
|
||||
hide-details="auto"
|
||||
:rules="[required]"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-label class="mb-2">Display name *</v-label>
|
||||
<v-text-field
|
||||
v-model="user.displayName"
|
||||
hide-details="auto"
|
||||
:rules="[required]"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-label class="mb-2">Email</v-label>
|
||||
<v-text-field
|
||||
v-model="user.email"
|
||||
type="email"
|
||||
hide-details="auto"
|
||||
:rules="[required, email]"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-label class="mb-2">Roles</v-label>
|
||||
<UserRoleSelect
|
||||
v-model="user.roles"
|
||||
hide-details="auto"
|
||||
:rules="[required]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<template #actions>
|
||||
<div class="d-flex mt-8">
|
||||
<v-btn
|
||||
:loading="isLoading"
|
||||
color="primary"
|
||||
type="submit"
|
||||
>
|
||||
Save User
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="warning"
|
||||
class="ml-4"
|
||||
variant="outlined"
|
||||
:loading="isLoading"
|
||||
:to="{ name: 'administration/UsersPage' }"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="canDelete"
|
||||
color="error"
|
||||
class="ml-4"
|
||||
variant="outlined"
|
||||
:loading="isDeleting"
|
||||
@click="showDeleteDialog = true"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</HeaderActionsFormCard>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="showDeleteDialog"
|
||||
title="Delete user?"
|
||||
:message="`Permanently delete ${user?.displayName || user?.email}? This removes the user from all organizations and roles and cannot be undone.`"
|
||||
confirm-text="Delete"
|
||||
confirm-color="error"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs } from "vue"
|
||||
import { RouteLocationRaw, useRouter } from "vue-router"
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import { email, required } from "@/utils/validators"
|
||||
|
||||
import usersApi from "@/api/users-api"
|
||||
import useCurrentUser from "@/use/use-current-user"
|
||||
import useSnack from "@/use/use-snack"
|
||||
import useUser from "@/use/use-user"
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog.vue"
|
||||
import HeaderActionsFormCard from "@/components/common/HeaderActionsFormCard.vue"
|
||||
import UserRoleSelect from "@/components/users/UserRoleSelect.vue"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
userId: number
|
||||
returnTo?: RouteLocationRaw
|
||||
}>(),
|
||||
{
|
||||
returnTo: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [userId: number]
|
||||
}>()
|
||||
|
||||
const { userId } = toRefs(props)
|
||||
const { user, policy, isLoading, save } = useUser(userId)
|
||||
|
||||
const { currentUser } = useCurrentUser()
|
||||
const router = useRouter()
|
||||
const headerActionsFormCard = ref<InstanceType<typeof HeaderActionsFormCard> | null>(null)
|
||||
const snack = useSnack()
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
const canDelete = computed(() => {
|
||||
if (!policy.value?.destroy) return false
|
||||
if (currentUser.value?.id === userId.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
async function handleDelete() {
|
||||
if (isNil(user.value)) return
|
||||
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await usersApi.delete(user.value.id)
|
||||
snack.success("User deleted.")
|
||||
await router.push({ name: "administration/UsersPage" })
|
||||
} catch (error) {
|
||||
snack.error(`Failed to delete user: ${error}`)
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWrapper() {
|
||||
if (isNil(user.value)) return
|
||||
if (headerActionsFormCard.value === null) return
|
||||
|
||||
const { valid } = await headerActionsFormCard.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
await save()
|
||||
snack.success("User saved!")
|
||||
emit("saved", user.value.id)
|
||||
} catch (error) {
|
||||
snack.error(`Failed to save user: ${error}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<v-skeleton-loader
|
||||
v-if="isNil(user)"
|
||||
type="card"
|
||||
/>
|
||||
<HeaderActionsCard
|
||||
v-else
|
||||
title="User Details"
|
||||
elevation="10"
|
||||
>
|
||||
<template #header-actions>
|
||||
<v-btn
|
||||
:to="{
|
||||
name: 'profile/ProfileEditPage',
|
||||
}"
|
||||
color="primary"
|
||||
>
|
||||
Edit
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<DescriptionElement
|
||||
label="First name"
|
||||
:model-value="user.firstName || '<blank>'"
|
||||
vertical
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<DescriptionElement
|
||||
label="Last name"
|
||||
:model-value="user.lastName || '<blank>'"
|
||||
vertical
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<DescriptionElement
|
||||
label="Email"
|
||||
:model-value="user.email"
|
||||
vertical
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<DescriptionElement
|
||||
label="Display name"
|
||||
:model-value="user.displayName || '<blank>'"
|
||||
vertical
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
</HeaderActionsCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from "vue"
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import useUser from "@/use/use-user"
|
||||
|
||||
import HeaderActionsCard from "@/components/common/HeaderActionsCard.vue"
|
||||
import DescriptionElement from "@/components/common/DescriptionElement.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
userId: number
|
||||
}>()
|
||||
|
||||
const { userId } = toRefs(props)
|
||||
const { user } = useUser(userId)
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<v-select
|
||||
v-model="selectedRoles"
|
||||
:items="roleItems"
|
||||
label="Roles"
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
v-bind="$attrs"
|
||||
></v-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from "vue-i18n"
|
||||
|
||||
import { UserRoles } from "@/api/users-api"
|
||||
|
||||
const selectedRoles = defineModel<UserRoles[]>({
|
||||
default: [],
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const ORDERED_ROLES = [UserRoles.USER, UserRoles.SYSTEM_ADMIN]
|
||||
|
||||
const roleItems = Object.values(ORDERED_ROLES).map((value) => ({
|
||||
title: t(`user.roles.${value}`),
|
||||
value,
|
||||
}))
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<v-data-table-server
|
||||
v-model:items-per-page="perPage"
|
||||
:page="page"
|
||||
:headers="headers"
|
||||
:items="users"
|
||||
:items-length="totalCount"
|
||||
:loading="isLoading"
|
||||
@click:row="rowClicked"
|
||||
@update:page="updatePage"
|
||||
>
|
||||
<template #item.roles="{ item }"> {{ item.roles?.join(", ") }} affaafafa </template>
|
||||
<template #item.updatedAt="{ value }">{{ formatDate(value) }}</template>
|
||||
<template #item.createdAt="{ value }">{{ formatDate(value) }}</template>
|
||||
<template
|
||||
v-for="(_, name) in $slots"
|
||||
:key="name"
|
||||
#[name]="slotProps"
|
||||
>
|
||||
<slot
|
||||
:name="name"
|
||||
v-bind="slotProps"
|
||||
></slot>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const DEFAULT_HEADERS = [
|
||||
{
|
||||
title: "Name",
|
||||
key: "displayName",
|
||||
},
|
||||
{
|
||||
title: "Email",
|
||||
key: "email",
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
key: "roles",
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
import { formatDate } from "@/utils/formatters"
|
||||
|
||||
import useUsers, {
|
||||
User,
|
||||
UserFiltersOptions,
|
||||
UserQueryOptions,
|
||||
UserWhereOptions,
|
||||
} from "@/use/use-users"
|
||||
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
headers?: { title: string; key: string }[]
|
||||
filters?: UserFiltersOptions
|
||||
where?: UserWhereOptions
|
||||
waiting?: boolean
|
||||
routeQuerySuffix?: string
|
||||
}>(),
|
||||
{
|
||||
headers: () => DEFAULT_HEADERS,
|
||||
filters: () => ({}),
|
||||
where: () => ({}),
|
||||
waiting: false,
|
||||
routeQuerySuffix: "Users",
|
||||
}
|
||||
)
|
||||
|
||||
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: "Users", perPage: 100 })
|
||||
|
||||
const userQueryOptions = computed<UserQueryOptions>(() => {
|
||||
return {
|
||||
where: props.where,
|
||||
filters: props.filters,
|
||||
perPage: perPage.value,
|
||||
page: page.value,
|
||||
}
|
||||
})
|
||||
|
||||
const { users, totalCount, isLoading, refresh } = useUsers(userQueryOptions, {
|
||||
skipWatchIf: () => props.waiting,
|
||||
})
|
||||
|
||||
type UserTableRow = {
|
||||
item: User
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ clicked: [userId: User] }>()
|
||||
|
||||
function rowClicked(_event: unknown, row: UserTableRow) {
|
||||
emit("clicked", row.item)
|
||||
}
|
||||
|
||||
function updatePage(newPage: number) {
|
||||
if (isLoading.value || props.waiting) return
|
||||
|
||||
page.value = newPage
|
||||
}
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
Reference in New Issue
Block a user