Frontend clean up
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<v-card
|
||||
elevation="10"
|
||||
hover
|
||||
>
|
||||
<v-card-item>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar
|
||||
:color="color"
|
||||
size="56"
|
||||
class="mr-4"
|
||||
>
|
||||
<slot name="icon"></slot>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<v-card-title class="text-h5">{{ title }}</v-card-title>
|
||||
<v-card-subtitle>{{ subtitle }}</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">{{ count ?? " " }}</span>
|
||||
<v-icon
|
||||
size="large"
|
||||
:color="color"
|
||||
>
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">{{ countLabel }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
count?: number | null
|
||||
countLabel?: string
|
||||
color?: string
|
||||
}>(),
|
||||
{
|
||||
subtitle: "",
|
||||
count: null,
|
||||
countLabel: "",
|
||||
color: "primary",
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<v-text-field
|
||||
ref="textField"
|
||||
:model-value="modelValue"
|
||||
:label="label"
|
||||
:rules="[...rules, valueMustBeUnique]"
|
||||
@update:model-value="updateModelValue"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-icon
|
||||
v-if="isNil(isUniqueValue)"
|
||||
:title="uniqueValidationMessage"
|
||||
>mdi-progress-clock</v-icon
|
||||
>
|
||||
<v-progress-circular
|
||||
v-else-if="isCheckingUniqueness"
|
||||
size="18"
|
||||
width="2"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<v-icon
|
||||
v-else-if="isUniqueValue === true"
|
||||
:title="isUniqueMessage"
|
||||
color="success"
|
||||
>mdi-check-circle-outline</v-icon
|
||||
>
|
||||
<v-icon
|
||||
v-else
|
||||
color="warning"
|
||||
:title="isNotUniqueMessage"
|
||||
>mdi-alert-circle-outline</v-icon
|
||||
>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { debounce, isEmpty, isNil } from "lodash"
|
||||
|
||||
import { VTextField } from "vuetify/components"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string | null | undefined
|
||||
label: string
|
||||
checkAvailability: (value: string) => Promise<boolean>
|
||||
rules?: VTextField["rules"]
|
||||
uniqueValidationMessage?: string
|
||||
isUniqueMessage?: string
|
||||
isNotUniqueMessage?: string
|
||||
}>(),
|
||||
{
|
||||
rules: () => [],
|
||||
uniqueValidationMessage: "Value must be unique",
|
||||
isUniqueMessage: "Value is unique",
|
||||
isNotUniqueMessage: "Value is not unique",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string]
|
||||
}>()
|
||||
|
||||
const isUniqueValue = ref<boolean | null>(null)
|
||||
const isCheckingUniqueness = ref(false)
|
||||
const textField = ref<InstanceType<typeof VTextField> | null>(null)
|
||||
|
||||
async function updateModelValue(value: string) {
|
||||
emit("update:modelValue", value)
|
||||
|
||||
await debouncedCheckValueIsUnique(value)
|
||||
}
|
||||
|
||||
async function checkValueIsUnique(value: string | null | undefined) {
|
||||
if (isNil(value) || isEmpty(value)) return null
|
||||
|
||||
isCheckingUniqueness.value = true
|
||||
try {
|
||||
const result = await props.checkAvailability(value)
|
||||
isUniqueValue.value = result
|
||||
await textField.value?.validate(false)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return false
|
||||
} finally {
|
||||
isCheckingUniqueness.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedCheckValueIsUnique = debounce(checkValueIsUnique, 500)
|
||||
|
||||
function valueMustBeUnique() {
|
||||
if (isUniqueValue.value === true || isUniqueValue.value === null) return true
|
||||
|
||||
return props.uniqueValidationMessage
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate: () => checkValueIsUnique(props.modelValue),
|
||||
})
|
||||
</script>
|
||||
@@ -10,31 +10,15 @@
|
||||
</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,34 @@
|
||||
<template>
|
||||
<UniqueTextField
|
||||
ref="uniqueTextField"
|
||||
v-model="userEmail"
|
||||
label="Email"
|
||||
:check-availability="checkEmailAvailability"
|
||||
unique-validation-message="User email must be unique"
|
||||
is-unique-message="User email is available"
|
||||
is-not-unique-message="User email is already taken"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
|
||||
import usersApi from "@/api/users-api"
|
||||
|
||||
import UniqueTextField from "@/components/common/UniqueTextField.vue"
|
||||
|
||||
const userEmail = defineModel<string | null | undefined>({
|
||||
required: true,
|
||||
})
|
||||
|
||||
async function checkEmailAvailability(email: string) {
|
||||
const { users } = await usersApi.list({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
return users.length === 0
|
||||
}
|
||||
|
||||
const uniqueTextField = ref<InstanceType<typeof UniqueTextField> | null>(null)
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<v-chip
|
||||
:color="getRoleColor(role)"
|
||||
class="my-0"
|
||||
:text="roleText"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
|
||||
import { UserRoles } from "@/api/users-api"
|
||||
|
||||
const props = defineProps<{ role: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const roleText = t(`user.roles.${props.role}`, props.role)
|
||||
|
||||
function getRoleColor(roleName: string) {
|
||||
switch (roleName) {
|
||||
case UserRoles.SYSTEM_ADMIN:
|
||||
return "red"
|
||||
default:
|
||||
return "primary"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -9,7 +9,14 @@
|
||||
@click:row="rowClicked"
|
||||
@update:page="updatePage"
|
||||
>
|
||||
<template #item.roles="{ item }"> {{ item.roles?.join(", ") }} affaafafa </template>
|
||||
<template #item.roles="{ item }">
|
||||
<UserRoleChip
|
||||
v-for="role in item.roles"
|
||||
:key="role"
|
||||
:role="role"
|
||||
class="mr-1"
|
||||
/>
|
||||
</template>
|
||||
<template #item.updatedAt="{ value }">{{ formatDate(value) }}</template>
|
||||
<template #item.createdAt="{ value }">{{ formatDate(value) }}</template>
|
||||
<template
|
||||
@@ -55,6 +62,8 @@ import useUsers, {
|
||||
} from "@/use/use-users"
|
||||
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||
|
||||
import UserRoleChip from "@/components/users/UserRoleChip.vue"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
headers?: { title: string; key: string }[]
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<AiChatPanel
|
||||
v-if="showAiPanel"
|
||||
v-model="showAiPanel"
|
||||
/>
|
||||
</v-layout>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div v-if="isSystemAdmin">
|
||||
<AppCard :to="{ name: 'administration/DashboardPage' }">You are a system admin</AppCard>
|
||||
<AppCard :to="{ name: 'administration/AdministrationDashboardPage' }"
|
||||
>You are a system admin</AppCard
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,105 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<!-- Users Card -->
|
||||
<v-row v-if="isSystemAdmin">
|
||||
<v-col
|
||||
v-if="isSystemAdmin"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
>
|
||||
<v-card
|
||||
elevation="10"
|
||||
<DashboardCard
|
||||
:to="{ name: 'administration/UsersPage' }"
|
||||
hover
|
||||
>
|
||||
<v-card-item>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar
|
||||
color="success"
|
||||
size="56"
|
||||
class="mr-4"
|
||||
title="Users"
|
||||
subtitle="Manage users and permissions"
|
||||
:count="usersCount"
|
||||
count-label="Total Users"
|
||||
color="green"
|
||||
>
|
||||
<template #icon>
|
||||
<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>
|
||||
icon="mdi-account-group"
|
||||
/>
|
||||
</template>
|
||||
</DashboardCard>
|
||||
</v-col>
|
||||
|
||||
<!-- Settings Card -->
|
||||
<v-col
|
||||
v-if="isSystemAdmin"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
>
|
||||
<v-card
|
||||
elevation="10"
|
||||
<DashboardCard
|
||||
:to="{ name: 'administration/SettingsPage' }"
|
||||
hover
|
||||
>
|
||||
<v-card-item>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar
|
||||
title="Settings"
|
||||
subtitle="System configuration"
|
||||
count-label="Config Settings"
|
||||
color="info"
|
||||
size="56"
|
||||
class="mr-4"
|
||||
>
|
||||
<template #icon>
|
||||
<v-icon
|
||||
size="32"
|
||||
color="white"
|
||||
>
|
||||
mdi-cog
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<v-card-title class="text-h5">Settings</v-card-title>
|
||||
<v-card-subtitle>System configuration</v-card-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span class="text-h3 font-weight-bold"> </span>
|
||||
|
||||
<v-icon
|
||||
size="large"
|
||||
color="info"
|
||||
>
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">Configure System</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
icon="mdi-cog"
|
||||
/>
|
||||
</template>
|
||||
</DashboardCard>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
@@ -110,6 +53,8 @@ import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||
import useUsers from "@/use/use-users"
|
||||
import useCurrentUser from "@/use/use-current-user"
|
||||
|
||||
import DashboardCard from "@/components/common/DashboardCard.vue"
|
||||
|
||||
const { isSystemAdmin } = useCurrentUser()
|
||||
|
||||
const { totalCount: usersCount } = useUsers()
|
||||
|
||||
@@ -53,114 +53,8 @@
|
||||
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
|
||||
@@ -192,12 +86,11 @@ 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 HeaderActionsFormCard from "@/components/common/HeaderActionsFormCard.vue"
|
||||
import UserEmailUniqueTextField from "@/components/users/UserEmailUniqueTextField.vue"
|
||||
|
||||
const userAttributes = ref<Partial<User>>({
|
||||
@@ -205,88 +98,8 @@ const userAttributes = ref<Partial<User>>({
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user