Files
InvoiceShelf/resources/scripts-v2/layouts/partials/CompanySwitcher.vue
Darko Gjorgjijoski 774b2614f0 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>
2026-04-04 06:30:00 +02:00

272 lines
9.1 KiB
Vue

<template>
<div ref="companySwitchBar" class="relative rounded">
<div
class="
flex items-center justify-center px-3 h-8 md:h-9 ml-2 text-sm text-white
bg-white/20 rounded-lg cursor-pointer hover:bg-white/30 transition-colors
"
@click="isShow = !isShow"
>
<span
v-if="companyStore.isAdminMode"
class="w-16 text-sm font-medium truncate sm:w-auto"
>
{{ $t('navigation.administration') }}
</span>
<span
v-else-if="companyStore.selectedCompany"
class="w-16 text-sm font-medium truncate sm:w-auto"
>
{{ companyStore.selectedCompany.name }}
</span>
<BaseIcon name="ChevronDownIcon" class="h-5 ml-1 text-white" />
</div>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isShow"
class="absolute right-0 mt-2 bg-surface rounded-md shadow-lg"
>
<div
class="
overflow-y-auto scrollbar-thin scrollbar-thumb-rounded-full
w-[300px] max-h-[350px]
scrollbar-thumb-surface-muted scrollbar-track-surface-secondary pb-4
"
>
<!-- Administration Mode -->
<div v-if="userStore.currentUser?.is_super_admin">
<div
class="p-2 px-3 rounded-md cursor-pointer hover:bg-hover-strong hover:text-primary-500"
:class="{
'bg-surface-tertiary text-primary-500': companyStore.isAdminMode,
}"
@click="enterAdminMode"
>
<div class="flex items-center">
<span
class="flex items-center justify-center mr-3 overflow-hidden text-base font-semibold bg-primary-100 rounded-md w-9 h-9 shrink-0 text-primary-500"
>
<BaseIcon name="ShieldCheckIcon" class="w-5 h-5" />
</span>
<div class="flex flex-col">
<span class="text-sm font-medium">{{ $t('navigation.administration') }}</span>
</div>
</div>
</div>
<div class="border-t border-line-light my-1" />
</div>
<label
class="px-3 py-2 text-xs font-semibold text-subtle mb-0.5 block uppercase"
>
{{ $t('company_switcher.label') }}
</label>
<div
v-if="companyStore.companies.length < 1"
class="flex flex-col items-center justify-center p-2 px-3 mt-4 text-base text-subtle"
>
<BaseIcon name="ExclamationCircleIcon" class="h-5 text-subtle" />
{{ $t('company_switcher.no_results_found') }}
</div>
<div v-else>
<div v-if="companyStore.companies.length > 0">
<div
v-for="(company, index) in companyStore.companies"
:key="index"
class="p-2 px-3 rounded-md cursor-pointer hover:bg-hover-strong hover:text-primary-500"
:class="{
'bg-surface-tertiary text-primary-500':
companyStore.selectedCompany && companyStore.selectedCompany.id === company.id,
}"
@click="changeCompany(company)"
>
<div class="flex items-center">
<span
class="
flex items-center justify-center mr-3 overflow-hidden text-base font-semibold
bg-surface-muted rounded-md w-9 h-9 text-primary-500
"
>
<span v-if="!company.logo">
{{ initGenerator(company.name) }}
</span>
<img
v-else
:src="company.logo"
alt="Company logo"
class="w-full h-full object-contain"
/>
</span>
<div class="flex flex-col">
<span class="text-sm">{{ company.name }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Pending Invitations -->
<div
v-if="pendingInvitations.length > 0"
class="border-t border-line-light p-2"
>
<label
class="block px-1 pt-1 pb-2 text-xs font-semibold leading-tight text-subtle uppercase"
>
{{ $t('members.pending_invitations') }}
</label>
<div
v-for="invitation in pendingInvitations"
:key="invitation.id"
class="p-2 px-3 rounded-md"
>
<div class="flex items-center mb-2">
<span
class="
flex items-center justify-center mr-3 overflow-hidden text-xs font-semibold
bg-surface-muted rounded-md w-9 h-9 shrink-0 text-subtle
"
>
{{ initGenerator(invitation.company?.name ?? '?') }}
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm text-body truncate">{{ invitation.company?.name }}</span>
<span class="text-xs text-subtle">{{ invitation.role?.title }}</span>
</div>
</div>
<div class="flex space-x-1 pl-12">
<button
class="text-xs px-2 py-1 rounded bg-primary-500 text-white hover:bg-primary-600"
@click.stop="acceptInvitation(invitation.token)"
>
{{ $t('general.accept') }}
</button>
<button
class="text-xs px-2 py-1 rounded bg-surface-muted text-body hover:bg-hover-strong"
@click.stop="declineInvitation(invitation.token)"
>
{{ $t('general.decline') }}
</button>
</div>
</div>
</div>
<div
v-if="userStore.currentUser?.is_owner"
class="
flex items-center justify-center p-4 pl-3 border-t-2 border-line-light
cursor-pointer text-primary-400 hover:text-primary-500
"
@click="addNewCompany"
>
<BaseIcon name="PlusIcon" class="h-5 mr-2" />
<span class="font-medium">
{{ $t('company_switcher.add_new_company') }}
</span>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '../../../stores/company.store'
import { useGlobalStore } from '../../../stores/global.store'
import { useUserStore } from '../../../stores/user.store'
import { useModalStore } from '../../../stores/modal.store'
import type { Company, CompanyInvitation } from '../../../types/domain/company'
import type { Role } from '../../../types/domain/role'
interface PendingInvitation {
id: number
token: string
company?: { name: string }
role?: { title: string }
}
const companyStore = useCompanyStore()
const modalStore = useModalStore()
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const { t } = useI18n()
const userStore = useUserStore()
const isShow = ref<boolean>(false)
const companySwitchBar = ref<HTMLElement | null>(null)
// TODO: Wire up pending invitations from a dedicated invitation store or bootstrap data
const pendingInvitations = ref<PendingInvitation[]>([])
watch(route, () => {
isShow.value = false
})
onClickOutside(companySwitchBar, () => {
isShow.value = false
})
function initGenerator(name: string): string {
if (name) {
const nameSplit = name.split(' ')
return nameSplit[0].charAt(0).toUpperCase()
}
return ''
}
function addNewCompany(): void {
modalStore.openModal({
title: t('company_switcher.new_company'),
componentName: 'CompanyModal',
size: 'sm',
})
}
async function enterAdminMode(): Promise<void> {
companyStore.setAdminMode(true)
isShow.value = false
router.push('/admin/administration/dashboard')
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
async function changeCompany(company: Company): Promise<void> {
companyStore.setAdminMode(false)
companyStore.setSelectedCompany(company)
router.push('/admin/dashboard')
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
async function acceptInvitation(token: string): Promise<void> {
// TODO: call invitation accept API, then re-bootstrap
pendingInvitations.value = pendingInvitations.value.filter(
(inv) => inv.token !== token
)
await globalStore.bootstrap()
}
async function declineInvitation(token: string): Promise<void> {
// TODO: call invitation decline API
pendingInvitations.value = pendingInvitations.value.filter(
(inv) => inv.token !== token
)
}
</script>