mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 19:24:03 +00:00
Rename resources/scripts-v2 to resources/scripts and drop @v2 alias
Now that the legacy v1 frontend (commit 064bdf53) is gone, the v2 directory is the only frontend and the v2 suffix is just noise. Renames resources/scripts-v2 to resources/scripts via git mv (so git records the move as renames, preserving blame and log --follow), then bulk-rewrites the 152 files that imported via @v2/... to use @/scripts/... instead. The existing @ alias (resources/) covers the new path with no extra config needed.
Drops the now-unused @v2 alias from vite.config.js and points the laravel-vite-plugin entry at resources/scripts/main.ts. Updates the only blade reference (resources/views/app.blade.php) to match. The package.json test script (eslint ./resources/scripts) automatically targets the right place after the rename without any edit.
Verified: npm run build exits clean and the Vite warning lines now reference resources/scripts/plugins/i18n.ts, confirming every import resolved through the new path. git log --follow on any moved file walks back through its scripts-v2 history.
This commit is contained in:
277
resources/scripts/layouts/partials/CompanySwitcher.vue
Normal file
277
resources/scripts/layouts/partials/CompanySwitcher.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<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>
|
||||
<span v-if="company.user_role" class="text-xs text-subtle">
|
||||
{{ company.user_role }}
|
||||
</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>
|
||||
|
||||
<CompanyModal />
|
||||
</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 '@/scripts/stores/company.store'
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import CompanyModal from '@/scripts/features/company/settings/components/CompanyModal.vue'
|
||||
import type { Company, CompanyInvitation } from '@/scripts/types/domain/company'
|
||||
import type { Role } from '@/scripts/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>
|
||||
179
resources/scripts/layouts/partials/GlobalSearchBar.vue
Normal file
179
resources/scripts/layouts/partials/GlobalSearchBar.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div ref="searchBar" class="hidden rounded md:block relative">
|
||||
<div>
|
||||
<BaseInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('global_search.search')"
|
||||
container-class="!rounded-lg !shadow-none"
|
||||
class="h-8 md:h-9 !rounded-lg !bg-white/20 !border-white/10 !text-white !placeholder-white/60"
|
||||
@input="onSearchInput"
|
||||
>
|
||||
<template #left>
|
||||
<BaseIcon name="MagnifyingGlassIcon" class="!text-white/70" />
|
||||
</template>
|
||||
<template #right>
|
||||
<span v-if="isSearching" class="h-5 w-5 animate-spin text-primary-500" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</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="
|
||||
scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-surface-muted
|
||||
scrollbar-track-surface-secondary overflow-y-auto bg-surface rounded-md
|
||||
mt-2 shadow-lg p-3 absolute w-[300px] h-[200px] right-0
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="customerList.length < 1 && userList.length < 1"
|
||||
class="flex items-center justify-center text-subtle text-base flex-col mt-4"
|
||||
>
|
||||
<BaseIcon name="ExclamationCircleIcon" class="text-subtle" />
|
||||
{{ $t('global_search.no_results_found') }}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="customerList.length > 0">
|
||||
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
|
||||
{{ $t('global_search.customers') }}
|
||||
</label>
|
||||
<div
|
||||
v-for="(customer, index) in customerList"
|
||||
:key="index"
|
||||
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
|
||||
>
|
||||
<router-link
|
||||
:to="{ path: `/admin/customers/${customer.id}/view` }"
|
||||
class="flex items-center"
|
||||
>
|
||||
<span
|
||||
class="
|
||||
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
|
||||
bg-surface-muted rounded-full text-primary-500
|
||||
"
|
||||
>
|
||||
{{ initGenerator(customer.name) }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{{ customer.name }}</span>
|
||||
<span v-if="customer.contact_name" class="text-xs text-subtle">
|
||||
{{ customer.contact_name }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-subtle">{{ customer.email }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userList.length > 0" class="mt-2">
|
||||
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
|
||||
{{ $t('global_search.users') }}
|
||||
</label>
|
||||
<div
|
||||
v-for="(user, index) in userList"
|
||||
:key="index"
|
||||
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
|
||||
>
|
||||
<router-link
|
||||
:to="{ path: `/admin/members/${user.id}/edit` }"
|
||||
class="flex items-center"
|
||||
>
|
||||
<span
|
||||
class="
|
||||
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
|
||||
bg-surface-muted rounded-full text-primary-500
|
||||
"
|
||||
>
|
||||
{{ initGenerator(user.name) }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{{ user.name }}</span>
|
||||
<span class="text-xs text-subtle">{{ user.email }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { onClickOutside, useDebounceFn } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { client } from '@/scripts/api/client'
|
||||
import { API } from '@/scripts/api/endpoints'
|
||||
|
||||
interface SearchResult {
|
||||
id: number
|
||||
name: string
|
||||
email?: string
|
||||
contact_name?: string
|
||||
}
|
||||
|
||||
const isShow = ref<boolean>(false)
|
||||
const searchQuery = ref<string>('')
|
||||
const searchBar = ref<HTMLElement | null>(null)
|
||||
const isSearching = ref<boolean>(false)
|
||||
const customerList = ref<SearchResult[]>([])
|
||||
const userList = ref<SearchResult[]>([])
|
||||
const route = useRoute()
|
||||
|
||||
watch(route, () => {
|
||||
isShow.value = false
|
||||
searchQuery.value = ''
|
||||
})
|
||||
|
||||
onClickOutside(searchBar, () => {
|
||||
isShow.value = false
|
||||
searchQuery.value = ''
|
||||
})
|
||||
|
||||
const debouncedSearch = useDebounceFn(async () => {
|
||||
if (!searchQuery.value) {
|
||||
isShow.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isSearching.value = true
|
||||
|
||||
try {
|
||||
const { data } = await client.get(API.SEARCH, {
|
||||
params: { search: searchQuery.value },
|
||||
})
|
||||
|
||||
customerList.value = data.customers ?? []
|
||||
userList.value = data.users ?? []
|
||||
isShow.value = true
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}, 500)
|
||||
|
||||
function onSearchInput(): void {
|
||||
if (searchQuery.value === '') {
|
||||
isShow.value = false
|
||||
return
|
||||
}
|
||||
debouncedSearch()
|
||||
}
|
||||
|
||||
function initGenerator(name: string): string {
|
||||
if (name) {
|
||||
const nameSplit = name.split(' ')
|
||||
return nameSplit[0].charAt(0).toUpperCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
44
resources/scripts/layouts/partials/ImpersonationBanner.vue
Normal file
44
resources/scripts/layouts/partials/ImpersonationBanner.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isImpersonating"
|
||||
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-orange-600"
|
||||
>
|
||||
<BaseIcon name="ExclamationTriangleIcon" class="w-4 h-4 mr-2" />
|
||||
<span>{{ $t('administration.users.impersonating_banner') }}</span>
|
||||
<button
|
||||
class="ml-4 px-3 py-1 text-xs font-semibold text-orange-600 bg-white rounded hover:bg-orange-50"
|
||||
:disabled="isStopping"
|
||||
@click="stopImpersonating"
|
||||
>
|
||||
{{ $t('administration.users.stop_impersonating') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import * as ls from '@/scripts/utils/local-storage'
|
||||
import { client } from '@/scripts/api/client'
|
||||
import { API } from '@/scripts/api/endpoints'
|
||||
|
||||
const isStopping = ref<boolean>(false)
|
||||
|
||||
const isImpersonating = computed<boolean>(() => {
|
||||
return ls.get<string>('admin.impersonating') === 'true'
|
||||
})
|
||||
|
||||
async function stopImpersonating(): Promise<void> {
|
||||
isStopping.value = true
|
||||
|
||||
try {
|
||||
await client.post(API.SUPER_ADMIN_STOP_IMPERSONATING)
|
||||
} catch {
|
||||
// Token already cleaned up in store action
|
||||
}
|
||||
|
||||
ls.remove('admin.impersonating')
|
||||
ls.remove('auth.token')
|
||||
|
||||
window.location.href = '/admin/administration/users'
|
||||
}
|
||||
</script>
|
||||
229
resources/scripts/layouts/partials/SiteHeader.vue
Normal file
229
resources/scripts/layouts/partials/SiteHeader.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<header
|
||||
class="
|
||||
fixed top-0 left-0 z-20 flex items-center justify-between w-full
|
||||
px-4 py-3 md:h-16 md:px-8 bg-linear-to-r from-header-from to-header-to
|
||||
"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<router-link
|
||||
:to="companyStore.isAdminMode ? '/admin/administration/dashboard' : '/admin/dashboard'"
|
||||
class="
|
||||
text-lg not-italic font-black tracking-wider text-white
|
||||
brand-main font-base hidden md:block
|
||||
"
|
||||
>
|
||||
<img v-if="adminLogo" :src="adminLogo" class="h-6" />
|
||||
<MainLogo v-else class="h-6" light-color="white" dark-color="white" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Mobile toggle button -->
|
||||
<div
|
||||
:class="{ 'is-active': globalStore.isSidebarOpen }"
|
||||
class="
|
||||
flex float-left p-1 overflow-visible text-sm ease-linear bg-surface
|
||||
border-0 rounded cursor-pointer md:hidden md:ml-0 hover:bg-hover-strong
|
||||
"
|
||||
@click.prevent="onToggle"
|
||||
>
|
||||
<BaseIcon name="Bars3Icon" class="!w-6 !h-6 text-muted" />
|
||||
</div>
|
||||
|
||||
<ul class="flex float-right h-8 m-0 list-none md:h-9">
|
||||
<!-- Create dropdown -->
|
||||
<li
|
||||
v-if="hasCreateAbilities && !companyStore.isAdminMode"
|
||||
class="relative hidden float-left m-0 md:block"
|
||||
>
|
||||
<BaseDropdown width-class="w-48">
|
||||
<template #activator>
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-center w-8 h-8 ml-2 text-sm text-white
|
||||
bg-white/20 rounded-lg hover:bg-white/30 md:h-9 md:w-9
|
||||
"
|
||||
>
|
||||
<BaseIcon name="PlusIcon" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<router-link to="/admin/invoices/create">
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.CREATE_INVOICE)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="DocumentTextIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ $t('invoices.new_invoice') }}
|
||||
</BaseDropdownItem>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/estimates/create">
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.CREATE_ESTIMATE)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="DocumentIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ $t('estimates.new_estimate') }}
|
||||
</BaseDropdownItem>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/customers/create">
|
||||
<BaseDropdownItem
|
||||
v-if="userStore.hasAbilities(ABILITIES.CREATE_CUSTOMER)"
|
||||
>
|
||||
<BaseIcon
|
||||
name="UserIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ $t('customers.new_customer') }}
|
||||
</BaseDropdownItem>
|
||||
</router-link>
|
||||
</BaseDropdown>
|
||||
</li>
|
||||
|
||||
<!-- Global search -->
|
||||
<li v-if="!companyStore.isAdminMode" class="ml-2">
|
||||
<GlobalSearchBar
|
||||
v-if="
|
||||
userStore.currentUser?.is_owner ||
|
||||
userStore.hasAbilities(ABILITIES.VIEW_CUSTOMER)
|
||||
"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<!-- Company switcher -->
|
||||
<li>
|
||||
<CompanySwitcher />
|
||||
</li>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<li class="relative block float-left ml-2">
|
||||
<BaseDropdown width-class="w-48">
|
||||
<template #activator>
|
||||
<img
|
||||
:src="previewAvatar"
|
||||
class="block w-8 h-8 rounded-full ring-2 ring-white/30 md:h-9 md:w-9 object-cover"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<div class="px-3 py-2">
|
||||
<div class="flex items-center justify-between rounded-lg bg-surface-secondary p-1">
|
||||
<button
|
||||
v-for="opt in themeOptions"
|
||||
:key="opt.value"
|
||||
:class="[
|
||||
'flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
currentTheme === opt.value
|
||||
? 'bg-surface text-heading shadow-sm'
|
||||
: 'text-muted hover:text-body',
|
||||
]"
|
||||
@click.stop="setTheme(opt.value)"
|
||||
>
|
||||
<BaseIcon :name="opt.icon" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link to="/admin/settings/account-settings">
|
||||
<BaseDropdownItem>
|
||||
<BaseIcon
|
||||
name="CogIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ $t('navigation.settings') }}
|
||||
</BaseDropdownItem>
|
||||
</router-link>
|
||||
|
||||
<BaseDropdownItem @click="logout">
|
||||
<BaseIcon
|
||||
name="ArrowRightOnRectangleIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ $t('navigation.logout') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/scripts/stores/auth.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useTheme } from '@/scripts/composables/use-theme'
|
||||
import { ABILITIES } from '@/scripts/config/abilities'
|
||||
import { THEME } from '@/scripts/config/constants'
|
||||
import type { Theme } from '@/scripts/config/constants'
|
||||
import CompanySwitcher from './CompanySwitcher.vue'
|
||||
import GlobalSearchBar from './GlobalSearchBar.vue'
|
||||
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
|
||||
|
||||
interface ThemeOption {
|
||||
value: Theme
|
||||
icon: string
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const companyStore = useCompanyStore()
|
||||
const router = useRouter()
|
||||
const { currentTheme, setTheme } = useTheme()
|
||||
|
||||
const previewAvatar = computed<string>(() => {
|
||||
if (userStore.currentUser && userStore.currentUser.avatar !== 0) {
|
||||
return userStore.currentUser.avatar as string
|
||||
}
|
||||
return getDefaultAvatar()
|
||||
})
|
||||
|
||||
const adminLogo = computed<string | false>(() => {
|
||||
if (globalStore.globalSettings?.admin_portal_logo) {
|
||||
return '/storage/' + globalStore.globalSettings.admin_portal_logo
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const hasCreateAbilities = computed<boolean>(() => {
|
||||
return userStore.hasAbilities([
|
||||
ABILITIES.CREATE_INVOICE,
|
||||
ABILITIES.CREATE_ESTIMATE,
|
||||
ABILITIES.CREATE_CUSTOMER,
|
||||
])
|
||||
})
|
||||
|
||||
function getDefaultAvatar(): string {
|
||||
const imgUrl = new URL('$images/default-avatar.jpg', import.meta.url)
|
||||
return imgUrl.href
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function onToggle(): void {
|
||||
globalStore.setSidebarVisibility(true)
|
||||
}
|
||||
|
||||
const themeOptions: ThemeOption[] = [
|
||||
{ value: THEME.LIGHT, icon: 'SunIcon' },
|
||||
{ value: THEME.DARK, icon: 'MoonIcon' },
|
||||
{ value: THEME.SYSTEM, icon: 'ComputerDesktopIcon' },
|
||||
]
|
||||
</script>
|
||||
217
resources/scripts/layouts/partials/SiteSidebar.vue
Normal file
217
resources/scripts/layouts/partials/SiteSidebar.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<!-- MOBILE MENU -->
|
||||
<TransitionRoot as="template" :show="globalStore.isSidebarOpen">
|
||||
<Dialog
|
||||
as="div"
|
||||
class="fixed inset-0 z-40 flex md:hidden"
|
||||
@close="globalStore.setSidebarVisibility(false)"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<DialogOverlay class="fixed inset-0 bg-gray-600/75" />
|
||||
</TransitionChild>
|
||||
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition ease-in-out duration-300"
|
||||
enter-from="-translate-x-full"
|
||||
enter-to="translate-x-0"
|
||||
leave="transition ease-in-out duration-300"
|
||||
leave-from="translate-x-0"
|
||||
leave-to="-translate-x-full"
|
||||
>
|
||||
<div class="relative flex flex-col flex-1 w-full max-w-xs bg-surface">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-in-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in-out duration-300"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="absolute top-0 right-0 pt-2 -mr-12">
|
||||
<button
|
||||
class="
|
||||
flex items-center justify-center w-10 h-10 ml-1 rounded-full
|
||||
focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-white
|
||||
"
|
||||
@click="globalStore.setSidebarVisibility(false)"
|
||||
>
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
|
||||
<div class="flex items-center shrink-0 px-4 mb-10">
|
||||
<MainLogo
|
||||
class="block h-auto max-w-full w-36 text-primary-400"
|
||||
alt="InvoiceShelf Logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-for="(menu, index) in globalStore.menuGroups"
|
||||
:key="index"
|
||||
class="mt-5 space-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="menu[0] && menu[0].group_label"
|
||||
class="px-4 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider"
|
||||
>
|
||||
{{ $t(menu[0].group_label) }}
|
||||
</div>
|
||||
<router-link
|
||||
v-for="item in menu"
|
||||
:key="item.name"
|
||||
:to="item.link"
|
||||
:class="[
|
||||
hasActiveUrl(item.link)
|
||||
? 'text-primary-600 bg-primary-50 font-semibold'
|
||||
: 'text-body hover:bg-hover',
|
||||
'cursor-pointer mx-3 px-3 py-2.5 flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
|
||||
]"
|
||||
@click="globalStore.setSidebarVisibility(false)"
|
||||
>
|
||||
<BaseIcon
|
||||
:name="item.icon"
|
||||
:class="[
|
||||
hasActiveUrl(item.link)
|
||||
? 'text-primary-500'
|
||||
: 'text-subtle',
|
||||
'mr-3 shrink-0 h-5 w-5',
|
||||
]"
|
||||
@click="globalStore.setSidebarVisibility(false)"
|
||||
/>
|
||||
{{ $t(item.title) }}
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div class="shrink-0 w-14">
|
||||
<!-- Force sidebar to shrink to fit close icon -->
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
|
||||
<!-- DESKTOP MENU -->
|
||||
<div
|
||||
:class="[
|
||||
globalStore.isSidebarCollapsed ? 'w-16' : 'w-56 xl:w-64',
|
||||
]"
|
||||
class="
|
||||
hidden h-screen pb-0 overflow-y-auto overflow-x-hidden
|
||||
bg-surface/80 backdrop-blur-xl border-r border-white/10
|
||||
md:fixed md:flex md:flex-col md:inset-y-0 pt-16
|
||||
transition-all duration-300
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="(menu, index) in globalStore.menuGroups"
|
||||
:key="index"
|
||||
class="p-0 m-0 mt-4 list-none"
|
||||
>
|
||||
<div
|
||||
v-if="menu[0] && menu[0].group_label && !globalStore.isSidebarCollapsed"
|
||||
class="px-6 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider whitespace-nowrap"
|
||||
>
|
||||
{{ $t(menu[0].group_label) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="menu[0] && menu[0].group_label && globalStore.isSidebarCollapsed"
|
||||
class="mx-3 my-2 border-t border-line-light"
|
||||
/>
|
||||
<router-link
|
||||
v-for="item in menu"
|
||||
:key="item.name"
|
||||
:to="item.link"
|
||||
v-tooltip="globalStore.isSidebarCollapsed ? { content: $t(item.title), placement: 'right' } : null"
|
||||
:class="[
|
||||
hasActiveUrl(item.link)
|
||||
? 'text-primary-600 bg-primary-50 font-semibold'
|
||||
: 'text-body hover:bg-hover',
|
||||
globalStore.isSidebarCollapsed
|
||||
? 'cursor-pointer mx-2 px-0 py-2.5 group flex items-center justify-center rounded-lg text-sm font-medium transition-colors'
|
||||
: 'cursor-pointer mx-3 px-3 py-2.5 group flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
|
||||
]"
|
||||
>
|
||||
<BaseIcon
|
||||
:name="item.icon"
|
||||
:class="[
|
||||
hasActiveUrl(item.link)
|
||||
? 'text-primary-500'
|
||||
: 'text-subtle group-hover:text-body',
|
||||
globalStore.isSidebarCollapsed
|
||||
? 'shrink-0 h-6 w-6'
|
||||
: 'mr-3 shrink-0 h-5 w-5',
|
||||
]"
|
||||
/>
|
||||
|
||||
<span v-if="!globalStore.isSidebarCollapsed" class="whitespace-nowrap">
|
||||
{{ $t(item.title) }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Bottom toolbar -->
|
||||
<div class="mt-auto sticky bottom-0 border-t border-white/10 bg-surface/80 backdrop-blur-xl p-2 flex flex-col items-center gap-1">
|
||||
<button
|
||||
v-tooltip="globalStore.isSidebarCollapsed ? { content: $t('general.collapse'), placement: 'right' } : null"
|
||||
:class="[
|
||||
globalStore.isSidebarCollapsed
|
||||
? 'w-10 h-10 justify-center'
|
||||
: 'w-full px-3 h-10 justify-end',
|
||||
]"
|
||||
class="flex items-center rounded-lg text-subtle hover:text-body hover:bg-hover transition-colors"
|
||||
@click="globalStore.toggleSidebarCollapse()"
|
||||
>
|
||||
<BaseIcon
|
||||
:name="globalStore.isSidebarCollapsed ? 'ChevronDoubleRightIcon' : 'ChevronDoubleLeftIcon'"
|
||||
class="w-4 h-4 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
|
||||
|
||||
interface MenuItemData {
|
||||
name: string
|
||||
title: string
|
||||
icon: string
|
||||
link: string
|
||||
group_label?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
function hasActiveUrl(url: string): boolean {
|
||||
return route.path.indexOf(url) > -1
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user