Phase 4a: Feature modules — layouts, auth, admin, dashboard,

customers, items, invoices, estimates, shared document form

77 files, 14451 lines. Typed layouts (CompanyLayout, AuthLayout,
header, sidebar, company switcher), auth views (login, register,
forgot/reset password), admin feature (dashboard, companies, users,
settings with typed store), company features (dashboard with chart/
stats, customers CRUD, items CRUD, invoices CRUD with full store,
estimates CRUD with full store), and shared document form components
(items table, item row, totals, notes, tax popup, template select,
exchange rate converter, calculation composable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 06:30:00 +02:00
parent e43e515614
commit 774b2614f0
77 changed files with 14451 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.companies.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
class="flex-1 mt-2"
>
<BaseInput
v-model="filters.search"
type="text"
name="search"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.companies.no_companies')"
:description="$t('administration.companies.list_description')"
>
<BaseIcon name="BuildingOfficeIcon" class="mt-5 mb-4 h-16 w-16 text-subtle" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="companyTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.companies.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-owner="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.name }}</span>
<span v-else class="text-subtle">-</span>
</template>
<template #cell-owner_email="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.email }}</span>
<span v-else class="text-subtle">-</span>
</template>
<template #cell-actions="{ row }">
<AdminCompanyDropdown
:row="row.data"
:table="tableRef"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdminStore } from '../stores/admin.store'
import AdminCompanyDropdown from '../components/AdminCompanyDropdown.vue'
import type { Company } from '../../../types/domain/company'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface TableResult {
data: Company[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
const adminStore = useAdminStore()
const { t } = useI18n()
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const tableRef = ref<{ refresh: () => void } | null>(null)
const filters = reactive({
search: '',
})
const companyTableColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('administration.companies.company_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'owner',
label: t('administration.companies.owner'),
sortable: false,
},
{
key: 'owner_email',
label: t('general.email'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
const showEmptyScreen = computed<boolean>(() => {
return !adminStore.totalCompanies && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable(): void {
tableRef.value?.refresh()
}
async function fetchData({ page, sort }: FetchParams): Promise<TableResult> {
const params = {
search: filters.search || '',
orderByField: sort.fieldName || 'name',
orderBy: sort.order || 'asc',
page,
}
isFetchingInitialData.value = true
const response = await adminStore.fetchCompanies(params)
isFetchingInitialData.value = false
return {
data: response.data,
pagination: {
totalPages: response.meta.last_page,
currentPage: page,
totalCount: response.meta.total,
limit: 10,
},
}
}
function clearFilter(): void {
filters.search = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,274 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.companies.edit_company')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="/admin/administration/companies"
/>
<BaseBreadcrumbItem
:title="$t('administration.companies.edit_company')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('administration.companies.owner')"
:error="v$.owner_id.$error && v$.owner_id.$errors[0].$message"
required
>
<BaseMultiselect
v-model="formData.owner_id"
label="name"
:invalid="v$.owner_id.$error"
:options="searchUsers"
value-prop="id"
:can-deselect="false"
:can-clear="false"
searchable
:filter-results="false"
:min-chars="0"
:resolve-on-load="true"
:delay="300"
object
track-by="name"
@select="onOwnerSelect"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
<BaseInput v-model="formData.tax_id" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
<BaseInput v-model="formData.vat_id" type="text" />
</BaseInputGroup>
</BaseInputGrid>
<BaseDivider class="my-6" />
<h3 class="text-lg font-medium text-heading mb-4">
{{ $t('administration.companies.address') }}
</h3>
<BaseInputGrid>
<BaseInputGroup :label="$t('settings.company_info.phone')">
<BaseInput v-model="formData.address.phone" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.country')">
<BaseMultiselect
v-model="formData.address.country_id"
label="name"
:options="globalStore.countries"
value-prop="id"
:can-deselect="true"
:can-clear="false"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.state')">
<BaseInput v-model="formData.address.state" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.city')">
<BaseInput v-model="formData.address.city" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.zip')">
<BaseInput v-model="formData.address.zip" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.address')">
<BaseTextarea
v-model="formData.address.address_street_1"
rows="2"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useGlobalStore } from '../../../stores/global.store'
import { useAdminStore } from '../stores/admin.store'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
interface OwnerOption {
id: number
name: string
email: string
}
interface CompanyFormData {
name: string
owner_id: OwnerOption | null
vat_id: string
tax_id: string
address: {
address_street_1: string
address_street_2: string
country_id: number | null
state: string
city: string
phone: string
zip: string
}
}
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const adminStore = useAdminStore()
const { t } = useI18n()
const isLoading = ref<boolean>(true)
const isSaving = ref<boolean>(false)
const formData = reactive<CompanyFormData>({
name: '',
owner_id: null,
vat_id: '',
tax_id: '',
address: {
address_street_1: '',
address_street_2: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
owner_id: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
await globalStore.fetchCountries()
const response = await adminStore.fetchCompany(route.params.id as string)
const company = response.data
formData.name = company.name
formData.vat_id = company.vat_id ?? ''
formData.tax_id = company.tax_id ?? ''
if (company.owner) {
formData.owner_id = {
id: company.owner.id,
name: company.owner.name,
email: company.owner.email,
}
}
if (company.address) {
formData.address.address_street_1 = company.address.address_street_1 ?? ''
formData.address.address_street_2 = company.address.address_street_2 ?? ''
formData.address.country_id = company.address.country_id
formData.address.state = company.address.state ?? ''
formData.address.city = company.address.city ?? ''
formData.address.phone = company.address.phone ?? ''
formData.address.zip = company.address.zip ?? ''
}
isLoading.value = false
})
async function searchUsers(query: string): Promise<OwnerOption[]> {
const { data } = await client.get(API.SEARCH, {
params: {
search: query,
type: 'User',
},
})
return (data.users as Array<{ id: number; name: string; email: string }>).map((user) => ({
id: user.id,
name: `${user.name} (${user.email})`,
email: user.email,
}))
}
function onOwnerSelect(option: OwnerOption): void {
formData.owner_id = option
}
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
await adminStore.updateCompany(route.params.id as string, {
name: formData.name,
owner_id: formData.owner_id?.id ?? 0,
vat_id: formData.vat_id,
tax_id: formData.tax_id,
address: formData.address,
})
isSaving.value = false
router.push({ name: 'admin.companies.index' })
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<BasePage>
<BasePageHeader :title="$t('navigation.dashboard')">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('navigation.administration')"
to="/admin/administration/dashboard"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div v-if="isLoading" class="flex justify-center py-16">
<BaseGlobalLoader />
</div>
<div v-else class="grid grid-cols-1 gap-6 mt-6 md:grid-cols-2 lg:grid-cols-3">
<!-- App Version -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="ServerIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('general.app_version') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.app_version }}
</p>
</BaseCard>
<!-- PHP Version -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="CodeBracketIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">PHP</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.php_version }}
</p>
</BaseCard>
<!-- Database -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="CircleStackIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('general.database') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.database?.driver?.toUpperCase() }}
</p>
<p class="text-sm text-muted mt-1">
{{ data.database?.version }}
</p>
</BaseCard>
<!-- Companies -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="BuildingOfficeIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('navigation.companies') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.counts?.companies }}
</p>
</BaseCard>
<!-- Users -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="UsersIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('navigation.all_users') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.counts?.users }}
</p>
</BaseCard>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAdminStore } from '../stores/admin.store'
import type { AdminDashboardData } from '../stores/admin.store'
const adminStore = useAdminStore()
const isLoading = ref<boolean>(true)
const data = ref<Partial<AdminDashboardData>>({})
onMounted(async () => {
try {
data.value = await adminStore.fetchDashboard()
} finally {
isLoading.value = false
}
})
</script>

View File

@@ -0,0 +1,117 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.settings.title')" class="mb-6">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.settings.title')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<BaseMultiselect
v-model="currentSetting"
:options="menuItems"
:can-deselect="false"
value-prop="title"
track-by="title"
label="title"
object
@update:model-value="navigateToSetting"
/>
</div>
<div class="flex gap-8">
<div class="hidden mt-1 xl:block min-w-[240px] sticky top-20 self-start">
<BaseList>
<BaseListItem
v-for="(menuItem, index) in menuItems"
:key="index"
:title="menuItem.title"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<BaseIcon :name="menuItem.icon" />
</template>
</BaseListItem>
</BaseList>
</div>
<div class="w-full overflow-hidden">
<RouterView />
</div>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { useRoute, useRouter, RouterView } from 'vue-router'
import { useI18n } from 'vue-i18n'
interface SettingsMenuItem {
title: string
link: string
icon: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const currentSetting = ref<SettingsMenuItem | undefined>(undefined)
const menuItems = computed<SettingsMenuItem[]>(() => [
{
title: t('settings.mail.mail_config'),
link: '/admin/administration/settings/mail-configuration',
icon: 'EnvelopeIcon',
},
{
title: t('settings.menu_title.pdf_generation'),
link: '/admin/administration/settings/pdf-generation',
icon: 'DocumentIcon',
},
{
title: t('settings.menu_title.backup'),
link: '/admin/administration/settings/backup',
icon: 'CircleStackIcon',
},
{
title: t('settings.menu_title.file_disk'),
link: '/admin/administration/settings/file-disk',
icon: 'FolderIcon',
},
{
title: t('settings.menu_title.update_app'),
link: '/admin/administration/settings/update-app',
icon: 'ArrowPathIcon',
},
])
watchEffect(() => {
if (route.path === '/admin/administration/settings') {
router.push('/admin/administration/settings/mail-configuration')
}
const item = menuItems.value.find((item) => {
return route.path.indexOf(item.link) > -1
})
currentSetting.value = item
})
function hasActiveUrl(url: string): boolean {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting: SettingsMenuItem): void {
router.push(setting.link)
}
</script>

View File

@@ -0,0 +1,187 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.users.edit_user')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="/admin/administration/users"
/>
<BaseBreadcrumbItem
:title="$t('administration.users.edit_user')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('users.name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.email')"
:error="v$.email.$error && v$.email.$errors[0].$message"
required
>
<BaseInput
v-model="formData.email"
type="email"
:invalid="v$.email.$error"
@blur="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')">
<BaseInput v-model="formData.phone" type="text" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('users.password')"
:error="v$.password.$error && v$.password.$errors[0].$message"
>
<BaseInput
v-model="formData.password"
type="password"
:invalid="v$.password.$error"
autocomplete="new-password"
@blur="v$.password.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div v-if="userData" class="mt-6 text-sm text-muted">
<p>
<strong>{{ $t('administration.users.role') }}:</strong>
{{ userData.role }}
</p>
<p v-if="userData.companies && userData.companies.length" class="mt-1">
<strong>{{ $t('navigation.companies') }}:</strong>
{{ userData.companies.map((c) => c.name).join(', ') }}
</p>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { required, email, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useAdminStore } from '../stores/admin.store'
import type { User } from '../../../types/domain/user'
interface UserFormData {
name: string
email: string
phone: string
password: string
}
const route = useRoute()
const router = useRouter()
const adminStore = useAdminStore()
const { t } = useI18n()
const isLoading = ref<boolean>(true)
const isSaving = ref<boolean>(false)
const userData = ref<User | null>(null)
const formData = reactive<UserFormData>({
name: '',
email: '',
phone: '',
password: '',
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8)
),
},
}))
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
const response = await adminStore.fetchUser(route.params.id as string)
const user = response.data
userData.value = user
formData.name = user.name
formData.email = user.email
formData.phone = user.phone ?? ''
isLoading.value = false
})
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data: Record<string, string> = {
name: formData.name,
email: formData.email,
phone: formData.phone,
}
if (formData.password) {
data.password = formData.password
}
await adminStore.updateUser(route.params.id as string, data)
isSaving.value = false
router.push({ name: 'admin.users.index' })
}
</script>

View File

@@ -0,0 +1,271 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.users.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('users.name')"
class="flex-1 mt-2 mr-4"
>
<BaseInput
v-model="filters.display_name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.email')" class="flex-1 mt-2 mr-4">
<BaseInput
v-model="filters.email"
type="text"
name="email"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')" class="flex-1 mt-2">
<BaseInput
v-model="filters.phone"
type="text"
name="phone"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.users.no_users')"
:description="$t('administration.users.list_description')"
>
<BaseIcon name="UsersIcon" class="mt-5 mb-4 h-16 w-16 text-subtle" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="userTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.users.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-email="{ row }">
<span>{{ row.data.email }}</span>
</template>
<template #cell-role="{ row }">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getRoleBadgeClass(row.data.role)"
>
{{ row.data.role }}
</span>
</template>
<template #cell-companies="{ row }">
<div v-if="row.data.companies && row.data.companies.length" class="flex flex-wrap gap-1">
<span
v-for="company in row.data.companies.slice(0, 3)"
:key="company.id"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-tertiary text-body"
>
{{ company.name }}
</span>
<span
v-if="row.data.companies.length > 3"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-tertiary text-muted"
>
+{{ row.data.companies.length - 3 }}
</span>
</div>
<span v-else class="text-subtle">-</span>
</template>
<template #cell-actions="{ row }">
<AdminUserDropdown
:row="row.data"
:table="tableRef"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdminStore } from '../stores/admin.store'
import AdminUserDropdown from '../components/AdminUserDropdown.vue'
import type { User } from '../../../types/domain/user'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface TableResult {
data: User[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
const adminStore = useAdminStore()
const { t } = useI18n()
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const tableRef = ref<{ refresh: () => void } | null>(null)
const filters = reactive({
display_name: '',
email: '',
phone: '',
})
const userTableColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('users.name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'email',
label: t('general.email'),
},
{
key: 'role',
label: t('administration.users.role'),
sortable: false,
},
{
key: 'companies',
label: t('navigation.companies'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
const showEmptyScreen = computed<boolean>(() => {
return !adminStore.totalUsers && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable(): void {
tableRef.value?.refresh()
}
async function fetchData({ page, sort }: FetchParams): Promise<TableResult> {
const params = {
display_name: filters.display_name || '',
email: filters.email || '',
phone: filters.phone || '',
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await adminStore.fetchUsers(params)
isFetchingInitialData.value = false
return {
data: response.data,
pagination: {
totalPages: response.meta.last_page,
currentPage: page,
totalCount: response.meta.total,
limit: 10,
},
}
}
function getRoleBadgeClass(role: string | null): string {
switch (role) {
case 'super admin':
return 'bg-purple-100 text-purple-800'
case 'admin':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-surface-tertiary text-heading'
}
}
function clearFilter(): void {
filters.display_name = ''
filters.email = ''
filters.phone = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>