mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-18 18:54:07 +00:00
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>
272 lines
9.1 KiB
Vue
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>
|