mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 17:24:10 +00:00
Finalize Typescript restructure
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-surface/70 backdrop-blur-lg rounded-xl shadow-sm border border-white/15">
|
||||
<div class="bg-surface rounded-xl shadow-sm border border-line-default">
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="px-5 py-4 text-heading border-b border-line-light border-solid"
|
||||
|
||||
@@ -227,7 +227,7 @@ getFields()
|
||||
:close-on-select="true"
|
||||
max-height="220"
|
||||
position="top-end"
|
||||
width-class="w-92"
|
||||
width-class="w-auto min-w-[40rem]"
|
||||
class="mb-2"
|
||||
>
|
||||
<template #activator>
|
||||
|
||||
@@ -4,10 +4,14 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usePermissions } from '@v2/composables/use-permissions'
|
||||
import { useModal } from '@v2/composables/use-modal'
|
||||
import { useUserStore } from '@v2/stores/user.store'
|
||||
import { useModalStore } from '@v2/stores/modal.store'
|
||||
import { ABILITIES } from '@v2/config/abilities'
|
||||
import type { Customer, Address } from '@v2/types/domain'
|
||||
import { useCustomerStore } from '@v2/features/company/customers/store'
|
||||
import { useInvoiceStore } from '@v2/features/company/invoices/store'
|
||||
import { useEstimateStore } from '@v2/features/company/estimates/store'
|
||||
import { useRecurringInvoiceStore } from '@v2/features/company/recurring-invoices/store'
|
||||
import CustomerModal from '@v2/features/company/customers/components/CustomerModal.vue'
|
||||
|
||||
type DocumentType = 'estimate' | 'invoice' | 'recurring-invoice'
|
||||
|
||||
@@ -20,28 +24,11 @@ interface Validation {
|
||||
$errors: ValidationError[]
|
||||
}
|
||||
|
||||
interface SelectedCustomerData {
|
||||
id: number
|
||||
name: string
|
||||
billing?: Pick<Address, 'name' | 'city' | 'state' | 'zip'> | null
|
||||
shipping?: Pick<Address, 'name' | 'city' | 'state' | 'zip'> | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
valid?: Validation
|
||||
customerId?: number | null
|
||||
type?: DocumentType | null
|
||||
contentLoading?: boolean
|
||||
selectedCustomer?: SelectedCustomerData | null
|
||||
customers?: Customer[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', customerId: number): void
|
||||
(e: 'deselect'): void
|
||||
(e: 'edit', customerId: number): void
|
||||
(e: 'search', query: string): void
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -49,44 +36,112 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
customerId: null,
|
||||
type: null,
|
||||
contentLoading: false,
|
||||
selectedCustomer: null,
|
||||
customers: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { hasAbility } = usePermissions()
|
||||
const { openModal } = useModal()
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const customerStore = useCustomerStore()
|
||||
const invoiceStore = useInvoiceStore()
|
||||
const estimateStore = useEstimateStore()
|
||||
const recurringInvoiceStore = useRecurringInvoiceStore()
|
||||
|
||||
const search = ref<string | null>(null)
|
||||
const isSearchingCustomer = ref<boolean>(false)
|
||||
|
||||
const selectedCustomer = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'invoice':
|
||||
return invoiceStore.newInvoice.customer
|
||||
case 'estimate':
|
||||
return estimateStore.newEstimate.customer
|
||||
case 'recurring-invoice':
|
||||
return recurringInvoiceStore.newRecurringInvoice.customer
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch initial customers on setup
|
||||
async function fetchInitialCustomers(): Promise<void> {
|
||||
await customerStore.fetchCustomers({
|
||||
orderByField: '',
|
||||
orderBy: '',
|
||||
})
|
||||
}
|
||||
|
||||
// Select customer on setup if customerId is provided
|
||||
if (props.customerId) {
|
||||
if (props.type === 'invoice') {
|
||||
invoiceStore.selectCustomer(props.customerId)
|
||||
} else if (props.type === 'estimate') {
|
||||
estimateStore.selectCustomer(props.customerId)
|
||||
} else if (props.type === 'recurring-invoice') {
|
||||
recurringInvoiceStore.selectCustomer(props.customerId)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInitialCustomers()
|
||||
|
||||
const debounceSearchCustomer = useDebounceFn(() => {
|
||||
isSearchingCustomer.value = true
|
||||
searchCustomer()
|
||||
}, 500)
|
||||
|
||||
function searchCustomer(): void {
|
||||
if (search.value !== null) {
|
||||
emit('search', search.value)
|
||||
}
|
||||
async function searchCustomer(): Promise<void> {
|
||||
await customerStore.fetchCustomers({
|
||||
display_name: search.value ?? '',
|
||||
page: 1,
|
||||
})
|
||||
isSearchingCustomer.value = false
|
||||
}
|
||||
|
||||
function editCustomer(): void {
|
||||
if (props.selectedCustomer) {
|
||||
emit('edit', props.selectedCustomer.id)
|
||||
function selectNewCustomer(id: number, close: () => void): void {
|
||||
const params: Record<string, unknown> = { userId: id }
|
||||
if (route.params.id) params.model_id = route.params.id
|
||||
|
||||
if (props.type === 'invoice') {
|
||||
invoiceStore.getNextNumber(params, true)
|
||||
invoiceStore.selectCustomer(id)
|
||||
} else if (props.type === 'estimate') {
|
||||
estimateStore.getNextNumber(params, true)
|
||||
estimateStore.selectCustomer(id)
|
||||
} else if (props.type === 'recurring-invoice') {
|
||||
recurringInvoiceStore.selectCustomer(id)
|
||||
}
|
||||
|
||||
close()
|
||||
search.value = null
|
||||
}
|
||||
|
||||
function resetSelectedCustomer(): void {
|
||||
emit('deselect')
|
||||
if (props.type === 'invoice') {
|
||||
invoiceStore.resetSelectedCustomer()
|
||||
} else if (props.type === 'estimate') {
|
||||
estimateStore.resetSelectedCustomer()
|
||||
} else if (props.type === 'recurring-invoice') {
|
||||
recurringInvoiceStore.resetSelectedCustomer()
|
||||
}
|
||||
}
|
||||
|
||||
async function editCustomer(): Promise<void> {
|
||||
if (!selectedCustomer.value) return
|
||||
await customerStore.fetchCustomer(selectedCustomer.value.id)
|
||||
modalStore.openModal({
|
||||
title: t('customers.edit_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
})
|
||||
}
|
||||
|
||||
function openCustomerModal(): void {
|
||||
emit('create')
|
||||
customerStore.resetCurrentCustomer()
|
||||
modalStore.openModal({
|
||||
title: t('customers.add_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
variant: 'md',
|
||||
})
|
||||
}
|
||||
|
||||
function initGenerator(name: string): string {
|
||||
@@ -96,16 +151,13 @@ function initGenerator(name: string): string {
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function selectNewCustomer(id: number, close: () => void): void {
|
||||
emit('select', id)
|
||||
close()
|
||||
search.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<div>
|
||||
<CustomerModal />
|
||||
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
@@ -359,7 +411,7 @@ function selectNewCustomer(id: number, close: () => void): void {
|
||||
:placeholder="$t('general.search')"
|
||||
type="text"
|
||||
icon="search"
|
||||
@update:modelValue="(val: string | null) => debounceSearchCustomer()"
|
||||
@update:modelValue="() => debounceSearchCustomer()"
|
||||
/>
|
||||
|
||||
<ul
|
||||
@@ -372,7 +424,7 @@ function selectNewCustomer(id: number, close: () => void): void {
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="(customer, index) in customers"
|
||||
v-for="(customer, index) in customerStore.customers"
|
||||
:key="index"
|
||||
href="#"
|
||||
class="
|
||||
@@ -434,7 +486,7 @@ function selectNewCustomer(id: number, close: () => void): void {
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="customers.length === 0"
|
||||
v-if="customerStore.customers.length === 0"
|
||||
class="flex justify-center p-5 text-subtle"
|
||||
>
|
||||
<label class="text-base text-muted cursor-pointer">
|
||||
@@ -445,7 +497,7 @@ function selectNewCustomer(id: number, close: () => void): void {
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasAbility(ABILITIES.CREATE_CUSTOMER)"
|
||||
v-if="userStore.hasAbilities(ABILITIES.CREATE_CUSTOMER)"
|
||||
type="button"
|
||||
class="
|
||||
h-10
|
||||
@@ -483,4 +535,5 @@ function selectNewCustomer(id: number, close: () => void): void {
|
||||
</transition>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 transition-opacity bg-surface-secondary0/75"
|
||||
class="fixed inset-0 transition-opacity bg-black/50"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
@@ -59,9 +59,9 @@
|
||||
text-left
|
||||
align-bottom
|
||||
transition-all
|
||||
bg-surface/80 backdrop-blur-2xl
|
||||
rounded-xl border border-white/15
|
||||
shadow-xl
|
||||
bg-surface/95 backdrop-blur-xl backdrop-saturate-150
|
||||
rounded-xl border border-line-default
|
||||
shadow-2xl
|
||||
sm:my-8 sm:align-middle sm:w-full sm:p-6
|
||||
relative
|
||||
"
|
||||
@@ -86,12 +86,12 @@
|
||||
>
|
||||
<BaseIcon
|
||||
v-if="dialogStore.variant === 'primary'"
|
||||
name="CheckIcon"
|
||||
name="CheckCircleIcon"
|
||||
class="w-6 h-6 text-alert-success-text"
|
||||
/>
|
||||
<BaseIcon
|
||||
v-else
|
||||
name="ExclamationIcon"
|
||||
name="ExclamationTriangleIcon"
|
||||
class="w-6 h-6 text-alert-error-text"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -11,40 +11,49 @@
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
<Menu v-else>
|
||||
<MenuButton ref="trigger" class="focus:outline-hidden" @click="onClick">
|
||||
<slot name="activator" />
|
||||
</MenuButton>
|
||||
<span ref="trigger" class="inline-flex">
|
||||
<MenuButton class="focus:outline-hidden" @click="onClick">
|
||||
<slot name="activator" />
|
||||
</MenuButton>
|
||||
</span>
|
||||
|
||||
<div ref="container" class="z-10" :class="widthClass">
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="scale-95 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-95 opacity-0"
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="container"
|
||||
class="fixed top-0 left-0 z-10"
|
||||
:class="[widthClass, !contentLoading ? 'pointer-events-none' : '']"
|
||||
>
|
||||
<MenuItems :class="containerClasses">
|
||||
<div class="py-1">
|
||||
<slot />
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</div>
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="scale-95 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-95 opacity-0"
|
||||
>
|
||||
<MenuItems :class="containerClasses">
|
||||
<div class="py-1">
|
||||
<slot />
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { usePopper } from '@v2/composables/use-popper'
|
||||
import type { Placement } from '@popperjs/core'
|
||||
|
||||
interface Props {
|
||||
containerClass?: string
|
||||
widthClass?: string
|
||||
positionClass?: string
|
||||
position?: string
|
||||
position?: Placement
|
||||
wrapperClass?: string
|
||||
contentLoading?: boolean
|
||||
}
|
||||
@@ -60,16 +69,19 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const containerClasses = computed<string>(() => {
|
||||
const baseClass = `origin-top-right rounded-xl shadow-xl bg-surface/80 backdrop-blur-xl border border-white/15 divide-y divide-line-light focus:outline-hidden`
|
||||
return `${baseClass} ${props.containerClass}`
|
||||
return `${baseClass} pointer-events-auto ${props.containerClass}`
|
||||
})
|
||||
|
||||
const [trigger, container, popper] = usePopper({
|
||||
placement: 'bottom-end',
|
||||
placement: props.position,
|
||||
strategy: 'fixed',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
|
||||
})
|
||||
|
||||
function onClick(): void {
|
||||
popper.value.update()
|
||||
async function onClick(): Promise<void> {
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
popper.value?.update()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePermissions } from '@v2/composables/use-permissions'
|
||||
import { useModal } from '@v2/composables/use-modal'
|
||||
import { useUserStore } from '@v2/stores/user.store'
|
||||
import { useModalStore } from '@v2/stores/modal.store'
|
||||
import { useItemStore } from '@v2/features/company/items/store'
|
||||
import { ABILITIES } from '@v2/config/abilities'
|
||||
import ItemModal from '@v2/features/company/items/components/ItemModal.vue'
|
||||
import type { Item } from '@v2/types/domain'
|
||||
import type { Tax } from '@v2/types/domain'
|
||||
|
||||
@@ -23,6 +25,8 @@ interface Props {
|
||||
invalidDescription?: boolean
|
||||
taxPerItem?: string
|
||||
taxes?: Tax[] | null
|
||||
store?: { deselectItem: (index: number) => void } | null
|
||||
storeProp?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -40,18 +44,27 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
invalidDescription: false,
|
||||
taxPerItem: '',
|
||||
taxes: null,
|
||||
store: null,
|
||||
storeProp: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { hasAbility } = usePermissions()
|
||||
const { openModal } = useModal()
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
const itemStore = useItemStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const itemSelect = ref<Item | null>(null)
|
||||
const multiselectRef = ref<{ close?: () => void } | null>(null)
|
||||
const loading = ref<boolean>(false)
|
||||
const itemData = reactive<LineItem>({ ...props.item })
|
||||
|
||||
async function searchItems(search: string): Promise<Item[]> {
|
||||
const res = await itemStore.fetchItems({ search })
|
||||
return res.data as unknown as Item[]
|
||||
}
|
||||
|
||||
const description = computed<string | null>({
|
||||
get: () => props.item.description,
|
||||
set: (value: string | null) => {
|
||||
@@ -60,25 +73,35 @@ const description = computed<string | null>({
|
||||
})
|
||||
|
||||
function openItemModal(): void {
|
||||
openModal({
|
||||
title: t('items.add_item'),
|
||||
componentName: 'ItemModal',
|
||||
refreshData: () => {},
|
||||
data: {
|
||||
taxPerItem: props.taxPerItem,
|
||||
taxes: props.taxes,
|
||||
itemIndex: props.index,
|
||||
},
|
||||
// Close the multiselect dropdown before opening the modal
|
||||
;(document.activeElement as HTMLElement)?.blur()
|
||||
|
||||
nextTick(() => {
|
||||
modalStore.openModal({
|
||||
title: t('items.add_item'),
|
||||
componentName: 'ItemModal',
|
||||
refreshData: (val: Item) => emit('select', val),
|
||||
data: {
|
||||
taxPerItem: props.taxPerItem,
|
||||
taxes: props.taxes,
|
||||
itemIndex: props.index,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function deselectItem(index: number): void {
|
||||
if (props.store) {
|
||||
props.store.deselectItem(index)
|
||||
}
|
||||
emit('deselect', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 text-sm">
|
||||
<ItemModal />
|
||||
|
||||
<!-- Selected Item Field -->
|
||||
<div
|
||||
v-if="item.item_id"
|
||||
@@ -106,6 +129,7 @@ function deselectItem(index: number): void {
|
||||
<!-- Select Item Field -->
|
||||
<BaseMultiselect
|
||||
v-else
|
||||
ref="multiselectRef"
|
||||
v-model="itemSelect"
|
||||
:content-loading="contentLoading"
|
||||
value-prop="id"
|
||||
@@ -118,6 +142,7 @@ function deselectItem(index: number): void {
|
||||
resolve-on-load
|
||||
:delay="500"
|
||||
searchable
|
||||
:options="searchItems"
|
||||
object
|
||||
@update:modelValue="(val: Item) => $emit('select', val)"
|
||||
@searchChange="(val: string) => $emit('search', val)"
|
||||
@@ -125,7 +150,7 @@ function deselectItem(index: number): void {
|
||||
<!-- Add Item Action -->
|
||||
<template #action>
|
||||
<BaseSelectAction
|
||||
v-if="hasAbility(ABILITIES.CREATE_ITEM)"
|
||||
v-if="userStore.hasAbilities(ABILITIES.CREATE_ITEM)"
|
||||
@click="openItemModal"
|
||||
>
|
||||
<BaseIcon
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 transition-opacity bg-gray-700/25"
|
||||
class="fixed inset-0 transition-opacity bg-black/50"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
@@ -51,17 +51,17 @@
|
||||
<div
|
||||
:class="`inline-block
|
||||
align-middle
|
||||
bg-surface/80 backdrop-blur-2xl
|
||||
rounded-xl border border-white/15
|
||||
bg-surface/95 backdrop-blur-xl backdrop-saturate-150
|
||||
rounded-xl border border-line-default
|
||||
text-left
|
||||
overflow-hidden
|
||||
relative
|
||||
shadow-xl
|
||||
shadow-2xl
|
||||
transition-all
|
||||
my-4
|
||||
${modalSize}
|
||||
sm:w-full
|
||||
border-t-8 border-solid rounded shadow-xl border-primary-500`"
|
||||
border-t-8 border-solid rounded border-primary-500`"
|
||||
>
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
79
resources/scripts-v2/components/base/BasePdfPreview.vue
Normal file
79
resources/scripts-v2/components/base/BasePdfPreview.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
src: string | false
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const status = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
|
||||
async function checkPdf(url: string): Promise<void> {
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', credentials: 'same-origin' })
|
||||
if (response.ok) {
|
||||
status.value = 'ready'
|
||||
} else {
|
||||
status.value = 'error'
|
||||
}
|
||||
} catch {
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function retry(): void {
|
||||
if (props.src) {
|
||||
checkPdf(props.src)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
(url) => {
|
||||
if (url) {
|
||||
checkPdf(url)
|
||||
} else {
|
||||
status.value = 'idle'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col min-h-0 mt-8 overflow-hidden"
|
||||
style="height: 75vh"
|
||||
>
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="status === 'loading' || status === 'idle'"
|
||||
class="flex-1 flex items-center justify-center border border-line-default rounded-md bg-surface"
|
||||
>
|
||||
<BaseSpinner class="w-8 h-8 text-primary-400" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-4 border border-line-default rounded-md bg-surface"
|
||||
>
|
||||
<BaseIcon name="ExclamationCircleIcon" class="w-12 h-12 text-muted" />
|
||||
<p class="text-sm text-muted">
|
||||
{{ $t('general.unable_to_load_pdf') }}
|
||||
</p>
|
||||
<BaseButton variant="primary-outline" size="sm" @click="retry">
|
||||
{{ $t('general.retry') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- PDF iframe -->
|
||||
<iframe
|
||||
v-else
|
||||
:src="src || undefined"
|
||||
class="flex-1 border border-line-default border-solid rounded-md bg-surface"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
24
resources/scripts-v2/components/base/BaseTab.vue
Normal file
24
resources/scripts-v2/components/base/BaseTab.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<TabPanel :class="[tabPanelContainer, 'focus:outline-hidden']">
|
||||
<slot />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabPanel } from '@headlessui/vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string | number
|
||||
count?: string | number
|
||||
countVariant?: string | number
|
||||
tabPanelContainer?: string
|
||||
}>(),
|
||||
{
|
||||
title: 'Tab',
|
||||
count: '',
|
||||
countVariant: '',
|
||||
tabPanelContainer: 'py-4 mt-px',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -40,7 +40,7 @@ function onChange(d: number): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<TabGroup :default-index="defaultIndex" @change="onChange">
|
||||
<TabList
|
||||
:class="[
|
||||
@@ -57,9 +57,9 @@ function onChange(d: number): void {
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'px-8 py-2 text-sm leading-5 font-medium flex items-center relative border-b-2 mt-4 focus:outline-hidden whitespace-nowrap',
|
||||
'px-5 py-2.5 text-sm leading-5 font-medium flex items-center relative -mb-px border-b-2 focus:outline-hidden whitespace-nowrap transition-colors',
|
||||
selected
|
||||
? ' border-primary-400 text-heading font-medium'
|
||||
? 'border-primary-400 text-heading'
|
||||
: 'border-transparent text-muted hover:text-body hover:border-line-strong',
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -10,28 +10,30 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case EstimateStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return 'bg-yellow-300/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300/50`
|
||||
case EstimateStatus.SENT:
|
||||
case 'SENT':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-300/50`
|
||||
case EstimateStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-300/50`
|
||||
case EstimateStatus.EXPIRED:
|
||||
case 'EXPIRED':
|
||||
return 'bg-red-300/25 px-2 py-1 text-sm text-status-red uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-red-50 text-red-700 ring-1 ring-inset ring-red-300/50`
|
||||
case EstimateStatus.ACCEPTED:
|
||||
case 'ACCEPTED':
|
||||
return 'bg-green-400/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-300/50`
|
||||
case EstimateStatus.REJECTED:
|
||||
case 'REJECTED':
|
||||
return 'bg-purple-300/25 px-2 py-1 text-sm text-status-purple uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-300/50`
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 px-2 py-1 text-sm text-heading uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { client } from '@v2/api/client'
|
||||
import InvoiceInformationCard from './InvoiceInformationCard.vue'
|
||||
import type { Currency } from '@v2/types/domain'
|
||||
import type { Company } from '@v2/types/domain'
|
||||
import type { Customer } from '@v2/types/domain'
|
||||
@@ -23,12 +25,6 @@ interface InvoicePublicData {
|
||||
customer?: Pick<Customer, 'name'>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
fetchInvoice: (hash: string) => Promise<InvoicePublicData>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const invoiceData = ref<InvoicePublicData | null>(null) as Ref<InvoicePublicData | null>
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -37,7 +33,8 @@ loadInvoice()
|
||||
|
||||
async function loadInvoice(): Promise<void> {
|
||||
const hash = route.params.hash as string
|
||||
invoiceData.value = await props.fetchInvoice(hash)
|
||||
const { data } = await client.get(`/customer/invoices/${hash}`)
|
||||
invoiceData.value = data.data
|
||||
}
|
||||
|
||||
const shareableLink = computed<string>(() => {
|
||||
@@ -60,13 +57,16 @@ const customerLogo = computed<string | false>(() => {
|
||||
const pageTitle: ComputedRef<string> = computed(() => invoiceData.value?.invoice_number ?? '')
|
||||
|
||||
function payInvoice(): void {
|
||||
router.push({
|
||||
name: 'invoice.pay',
|
||||
params: {
|
||||
hash: route.params.hash as string,
|
||||
company: invoiceData.value?.company?.slug ?? '',
|
||||
},
|
||||
})
|
||||
const resolved = router.resolve({ name: 'invoice.pay' })
|
||||
if (resolved.matched.length) {
|
||||
router.push({
|
||||
name: 'invoice.pay',
|
||||
params: {
|
||||
hash: route.params.hash as string,
|
||||
company: invoiceData.value?.company?.slug ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,35 +16,37 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case InvoiceStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return 'bg-yellow-300/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300/50`
|
||||
case InvoiceStatus.SENT:
|
||||
case 'SENT':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-300/50`
|
||||
case InvoiceStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-300/50`
|
||||
case InvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-green-50 text-green-700 ring-1 ring-inset ring-green-300/50`
|
||||
case 'DUE':
|
||||
return 'bg-yellow-500/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-300/50`
|
||||
case 'OVERDUE':
|
||||
return 'bg-red-300/50 px-2 py-1 text-sm text-status-red uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-red-50 text-red-700 ring-1 ring-inset ring-red-300/50`
|
||||
case InvoicePaidStatus.UNPAID:
|
||||
case 'UNPAID':
|
||||
return 'bg-yellow-500/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-orange-50 text-orange-700 ring-1 ring-inset ring-orange-300/50`
|
||||
case InvoicePaidStatus.PARTIALLY_PAID:
|
||||
case 'PARTIALLY_PAID':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-cyan-50 text-cyan-700 ring-1 ring-inset ring-cyan-300/50`
|
||||
case InvoicePaidStatus.PAID:
|
||||
case 'PAID':
|
||||
return 'bg-green-500/40 px-2 py-1 text-sm text-status-green uppercase font-semibold text-center'
|
||||
return `${baseClasses} bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-300/50`
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 px-2 py-1 text-sm text-heading uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -6,35 +6,35 @@ type PaidBadgeStatus = InvoicePaidStatus | 'OVERDUE' | string
|
||||
|
||||
interface Props {
|
||||
status?: PaidBadgeStatus
|
||||
defaultClass?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
defaultClass: 'px-1 py-0.5 text-xs',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case InvoicePaidStatus.PAID:
|
||||
case 'PAID':
|
||||
return 'bg-green-500/40 text-status-green uppercase font-semibold text-center'
|
||||
return `${baseClasses} bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-300/50`
|
||||
case InvoicePaidStatus.UNPAID:
|
||||
case 'UNPAID':
|
||||
return 'bg-yellow-500/25 text-status-yellow uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-orange-50 text-orange-700 ring-1 ring-inset ring-orange-300/50`
|
||||
case InvoicePaidStatus.PARTIALLY_PAID:
|
||||
case 'PARTIALLY_PAID':
|
||||
return 'bg-blue-400/25 text-status-blue uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-cyan-50 text-cyan-700 ring-1 ring-inset ring-cyan-300/50`
|
||||
case 'OVERDUE':
|
||||
return 'bg-red-300/50 px-2 py-1 text-sm text-status-red uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-red-50 text-red-700 ring-1 ring-inset ring-red-300/50`
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 text-heading uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="[badgeColorClasses, defaultClass]">
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -10,19 +10,21 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case RecurringInvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
case RecurringInvoiceStatus.ON_HOLD:
|
||||
case 'ON_HOLD':
|
||||
return 'bg-yellow-500/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
case RecurringInvoiceStatus.ACTIVE:
|
||||
case 'ACTIVE':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-300/50`
|
||||
case RecurringInvoiceStatus.ON_HOLD:
|
||||
case 'ON_HOLD':
|
||||
return `${baseClasses} bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-300/50`
|
||||
case RecurringInvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return `${baseClasses} bg-green-50 text-green-700 ring-1 ring-inset ring-green-300/50`
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 px-2 py-1 text-sm text-heading uppercase font-normal text-center'
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,7 @@ export { default as BaseListItem } from './BaseListItem.vue'
|
||||
export { default as BaseMoney } from './BaseMoney.vue'
|
||||
export { default as BaseModal } from './BaseModal.vue'
|
||||
export { default as BaseMultiselect } from './BaseMultiselect.vue'
|
||||
export { default as BasePdfPreview } from './BasePdfPreview.vue'
|
||||
export { default as BaseRadio } from './BaseRadio.vue'
|
||||
export { default as BaseRating } from './BaseRating.vue'
|
||||
export { default as BaseRecurringInvoiceStatusLabel } from './BaseRecurringInvoiceStatusLabel.vue'
|
||||
@@ -39,6 +40,7 @@ export { default as BaseSelectInput } from './BaseSelectInput.vue'
|
||||
export { default as BaseSettingCard } from './BaseSettingCard.vue'
|
||||
export { default as BaseSpinner } from './BaseSpinner.vue'
|
||||
export { default as BaseSwitch } from './BaseSwitch.vue'
|
||||
export { default as BaseTab } from './BaseTab.vue'
|
||||
export { default as BaseTabGroup } from './BaseTabGroup.vue'
|
||||
export { default as BaseText } from './BaseText.vue'
|
||||
export { default as BaseTextarea } from './BaseTextarea.vue'
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
<template>
|
||||
<transition
|
||||
enter-active-class="transition duration-500 ease-in-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in-out"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-show="show" class="relative z-10 p-4 md:p-8 bg-surface-muted rounded">
|
||||
<div
|
||||
v-show="show"
|
||||
class="relative z-10 p-5 md:p-6 bg-surface rounded-xl border border-line-default shadow-sm mb-4"
|
||||
>
|
||||
<slot name="filter-header" />
|
||||
|
||||
<label
|
||||
<button
|
||||
class="
|
||||
absolute
|
||||
text-sm
|
||||
leading-snug
|
||||
text-heading
|
||||
cursor-pointer
|
||||
hover:text-body
|
||||
top-2.5
|
||||
right-3.5
|
||||
absolute top-4 right-4
|
||||
flex items-center gap-1
|
||||
text-xs font-medium
|
||||
text-muted hover:text-heading
|
||||
px-2 py-1
|
||||
rounded-md
|
||||
hover:bg-surface-secondary
|
||||
transition-colors
|
||||
"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
<BaseIcon name="XMarkIcon" class="w-3.5 h-3.5" />
|
||||
{{ $t('general.clear_all') }}
|
||||
</label>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex flex-col space-y-3"
|
||||
:class="
|
||||
rowOnXl
|
||||
? 'xl:flex-row xl:space-x-4 xl:space-y-0 xl:items-center'
|
||||
: 'lg:flex-row lg:space-x-4 lg:space-y-0 lg:items-center'
|
||||
? 'xl:flex-row xl:space-x-4 xl:space-y-0 xl:items-end'
|
||||
: 'lg:flex-row lg:space-x-4 lg:space-y-0 lg:items-end'
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -80,10 +80,10 @@
|
||||
>
|
||||
{{
|
||||
notification.message
|
||||
? notification.message
|
||||
? $t(notification.message)
|
||||
: success
|
||||
? 'Successful'
|
||||
: 'Something went wrong'
|
||||
? $t('general.successful')
|
||||
: $t('general.something_went_wrong')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
class="
|
||||
relative
|
||||
overflow-hidden
|
||||
bg-surface/70 backdrop-blur-lg
|
||||
border border-white/15
|
||||
bg-surface
|
||||
border border-line-default
|
||||
shadow-sm
|
||||
rounded-xl
|
||||
"
|
||||
|
||||
Reference in New Issue
Block a user