Frontend clean up

This commit is contained in:
2026-06-20 00:17:24 -07:00
parent 118472945f
commit 9b8b41a1fe
10 changed files with 266 additions and 296 deletions
@@ -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 ?? "&nbsp;" }}</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> </div>
<v-spacer /> <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 /> <ProfileMenu />
</v-app-bar> </v-app-bar>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import useCurrentUser from "@/use/use-current-user"
import AppLogo from "@/components/common/AppLogo.vue" import AppLogo from "@/components/common/AppLogo.vue"
import ProfileMenu from "@/components/layout/ProfileMenu.vue" import ProfileMenu from "@/components/layout/ProfileMenu.vue"
defineEmits<{ defineEmits<{
"toggle-ai-panel": [] "toggle-ai-panel": []
}>() }>()
const { isSystemAdmin } = useCurrentUser<true>()
</script> </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>
+29
View File
@@ -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" @click:row="rowClicked"
@update:page="updatePage" @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.updatedAt="{ value }">{{ formatDate(value) }}</template>
<template #item.createdAt="{ value }">{{ formatDate(value) }}</template> <template #item.createdAt="{ value }">{{ formatDate(value) }}</template>
<template <template
@@ -55,6 +62,8 @@ import useUsers, {
} from "@/use/use-users" } from "@/use/use-users"
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination" import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
import UserRoleChip from "@/components/users/UserRoleChip.vue"
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
headers?: { title: string; key: string }[] headers?: { title: string; key: string }[]
-5
View File
@@ -19,11 +19,6 @@
<router-view /> <router-view />
</v-container> </v-container>
</v-main> </v-main>
<AiChatPanel
v-if="showAiPanel"
v-model="showAiPanel"
/>
</v-layout> </v-layout>
</template> </template>
+3 -1
View File
@@ -1,6 +1,8 @@
<template> <template>
<div v-if="isSystemAdmin"> <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> </div>
</template> </template>
@@ -1,105 +1,48 @@
<template> <template>
<div> <div>
<v-row> <v-row v-if="isSystemAdmin">
<!-- Users Card -->
<v-col <v-col
v-if="isSystemAdmin"
cols="12" cols="12"
md="6" md="6"
lg="4" lg="4"
> >
<v-card <DashboardCard
elevation="10"
:to="{ name: 'administration/UsersPage' }" :to="{ name: 'administration/UsersPage' }"
hover title="Users"
subtitle="Manage users and permissions"
:count="usersCount"
count-label="Total Users"
color="green"
> >
<v-card-item> <template #icon>
<div class="d-flex align-center"> <v-icon
<v-avatar size="32"
color="success" color="white"
size="56" icon="mdi-account-group"
class="mr-4" />
> </template>
<v-icon </DashboardCard>
size="32"
color="white"
>
mdi-account-group
</v-icon>
</v-avatar>
<div>
<v-card-title class="text-h5">Users</v-card-title>
<v-card-subtitle>Manage users and permissions</v-card-subtitle>
</div>
</div>
</v-card-item>
<v-divider />
<v-card-text>
<div class="d-flex align-center justify-space-between">
<span class="text-h3 font-weight-bold">{{ usersCount }}</span>
<v-icon
size="large"
color="success"
>
mdi-chevron-right
</v-icon>
</div>
<div class="text-caption text-medium-emphasis mt-1">Total Users</div>
</v-card-text>
</v-card>
</v-col> </v-col>
<!-- Settings Card -->
<v-col <v-col
v-if="isSystemAdmin"
cols="12" cols="12"
md="6" md="6"
lg="4" lg="4"
> >
<v-card <DashboardCard
elevation="10"
:to="{ name: 'administration/SettingsPage' }" :to="{ name: 'administration/SettingsPage' }"
hover title="Settings"
subtitle="System configuration"
count-label="Config Settings"
color="info"
> >
<v-card-item> <template #icon>
<div class="d-flex align-center"> <v-icon
<v-avatar size="32"
color="info" color="white"
size="56" icon="mdi-cog"
class="mr-4" />
> </template>
<v-icon </DashboardCard>
size="32"
color="white"
>
mdi-cog
</v-icon>
</v-avatar>
<div>
<v-card-title class="text-h5">Settings</v-card-title>
<v-card-subtitle>System configuration</v-card-subtitle>
</div>
</div>
</v-card-item>
<v-divider />
<v-card-text>
<div class="d-flex align-center justify-space-between">
<span class="text-h3 font-weight-bold">&nbsp;</span>
<v-icon
size="large"
color="info"
>
mdi-chevron-right
</v-icon>
</div>
<div class="text-caption text-medium-emphasis mt-1">Configure System</div>
</v-card-text>
</v-card>
</v-col> </v-col>
</v-row> </v-row>
</div> </div>
@@ -110,6 +53,8 @@ import useBreadcrumbs from "@/use/use-breadcrumbs"
import useUsers from "@/use/use-users" import useUsers from "@/use/use-users"
import useCurrentUser from "@/use/use-current-user" import useCurrentUser from "@/use/use-current-user"
import DashboardCard from "@/components/common/DashboardCard.vue"
const { isSystemAdmin } = useCurrentUser() const { isSystemAdmin } = useCurrentUser()
const { totalCount: usersCount } = useUsers() const { totalCount: usersCount } = useUsers()
@@ -53,114 +53,8 @@
validate-on="blur" validate-on="blur"
/> />
</v-col> </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-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> <template #actions>
<div class="d-flex"> <div class="d-flex">
<v-btn <v-btn
@@ -192,12 +86,11 @@ import { useRouter } from "vue-router"
import { isEmpty, isNil } from "lodash" import { isEmpty, isNil } from "lodash"
import { required, minimum, email } from "@/utils/validators" import { required, minimum, email } from "@/utils/validators"
import { resizeToStandard } from "@/utils/image-resizer"
import usersApi, { User } from "@/api/users-api" import usersApi, { User } from "@/api/users-api"
import useBreadcrumbs from "@/use/use-breadcrumbs" import useBreadcrumbs from "@/use/use-breadcrumbs"
import useSnack from "@/use/use-snack" 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" import UserEmailUniqueTextField from "@/components/users/UserEmailUniqueTextField.vue"
const userAttributes = ref<Partial<User>>({ const userAttributes = ref<Partial<User>>({
@@ -205,88 +98,8 @@ const userAttributes = ref<Partial<User>>({
displayName: "", displayName: "",
firstName: "", firstName: "",
lastName: "", 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) { function autoFillDependentFields(focused: boolean) {
if (focused) return if (focused) return