Files
InvoiceShelf/resources/scripts-v2/features/shared/document-form/DocumentTotals.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

389 lines
12 KiB
Vue

<template>
<div
class="px-5 py-4 mt-6 bg-surface border border-line-light border-solid rounded-xl shadow md:min-w-[390px] min-w-[300px] lg:mt-7"
>
<!-- Subtotal -->
<div class="flex items-center justify-between w-full">
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-subtle uppercase">
{{ $t('estimates.sub_total') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="store.getSubTotal" :currency="defaultCurrency" />
</label>
</div>
<!-- Net Total for per-item tax mode -->
<div v-if="formData.tax_per_item === 'YES'">
<div
v-if="formData.tax_included"
class="flex items-center justify-between w-full"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-muted uppercase">
{{ $t('estimates.net_total') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="store.getNetTotal" :currency="currency" />
</label>
</div>
</div>
<!-- Item-wise tax breakdown -->
<div
v-for="tax in itemWiseTaxes"
:key="tax.tax_type_id"
class="flex items-center justify-between w-full"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else-if="formData.tax_per_item === 'YES'"
class="m-0 text-sm font-semibold leading-5 text-muted uppercase"
>
<template v-if="tax.calculation_type === 'percentage'">
{{ tax.name }} - {{ tax.percent }}%
</template>
<template v-else>
{{ tax.name }} -
<BaseFormatMoney :amount="tax.fixed_amount" :currency="defaultCurrency" />
</template>
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else-if="formData.tax_per_item === 'YES'"
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="tax.amount" :currency="defaultCurrency" />
</label>
</div>
<!-- Global Discount -->
<div
v-if="formData.discount_per_item === 'NO' || formData.discount_per_item === null"
class="flex items-center justify-between w-full mt-2"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-subtle uppercase">
{{ $t('estimates.discount') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText
:lines="1"
class="w-24 h-8 border border-line-light rounded-md"
/>
</BaseContentPlaceholders>
<div v-else class="flex" style="width: 140px" role="group">
<BaseInput
v-model="totalDiscount"
class="border-r-0 focus:border-r-2 rounded-tr-sm rounded-br-sm h-[38px]"
/>
<BaseDropdown position="bottom-end">
<template #activator>
<BaseButton
class="p-2 rounded-none rounded-tr-md rounded-br-md"
type="button"
variant="white"
>
<span class="flex items-center">
{{ formData.discount_type === 'fixed' ? defaultCurrencySymbol : '%' }}
<BaseIcon name="ChevronDownIcon" class="w-4 h-4 ml-1 text-muted" />
</span>
</BaseButton>
</template>
<BaseDropdownItem @click="selectFixed">
{{ $t('general.fixed') }}
</BaseDropdownItem>
<BaseDropdownItem @click="selectPercentage">
{{ $t('general.percentage') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
</div>
<!-- Net Total for global tax mode -->
<div
v-if="formData.tax_per_item === 'NO' || formData.tax_per_item === null"
class="flex items-center justify-between w-full mt-2"
>
<div
v-if="formData.tax_included"
class="flex items-center justify-between w-full"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-muted uppercase">
{{ $t('estimates.net_total') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="store.getNetTotal" :currency="currency" />
</label>
</div>
</div>
<!-- Global taxes list -->
<div
v-if="formData.tax_per_item === 'NO' || formData.tax_per_item === null"
>
<div
v-for="(tax, index) in taxes"
:key="tax.id"
class="flex items-center justify-between w-full mt-2 text-sm"
>
<label v-if="tax.calculation_type === 'percentage'" class="font-semibold leading-5 text-muted uppercase">
{{ tax.name }} ({{ tax.percent }} %)
</label>
<label v-else class="font-semibold leading-5 text-muted uppercase">
{{ tax.name }} (<BaseFormatMoney :amount="tax.fixed_amount" :currency="currency" />)
</label>
<label class="flex items-center justify-center text-lg text-heading">
<BaseFormatMoney :amount="tax.amount" :currency="currency" />
<BaseIcon
name="TrashIcon"
class="h-5 ml-2 cursor-pointer"
@click="removeTax(tax.id)"
/>
</label>
</div>
</div>
<!-- Add tax popup -->
<div
v-if="formData.tax_per_item === 'NO' || formData.tax_per_item === null"
ref="taxModal"
class="float-right pt-2 pb-4"
>
<TaxSelectPopup
:store-prop="storeProp"
:store="store"
:type="taxPopupType"
@select:tax-type="onSelectTax"
/>
</div>
<!-- Total Amount -->
<div
class="flex items-center justify-between w-full pt-2 mt-5 border-t border-line-light border-solid"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="m-0 text-sm font-semibold leading-5 text-subtle uppercase">
{{ $t('estimates.total') }} {{ $t('estimates.amount') }}:
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center text-lg uppercase text-primary-400"
>
<BaseFormatMoney :amount="store.getTotal" :currency="defaultCurrency" />
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import TaxSelectPopup from './TaxSelectPopup.vue'
import type { Currency } from '../../../types/domain/currency'
import type { TaxType } from '../../../types/domain/tax'
import type { DocumentFormData, DocumentTax, DocumentStore, DocumentItem } from './use-document-calculations'
interface Props {
store: DocumentStore & {
$patch: (fn: (state: Record<string, unknown>) => void) => void
[key: string]: unknown
}
storeProp: string
taxPopupType?: string
currency?: Currency | Record<string, unknown> | string
isLoading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
taxPopupType: '',
currency: '',
isLoading: false,
})
const taxModal = ref<HTMLElement | null>(null)
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const defaultCurrency = computed(() => {
if (props.currency) {
return props.currency
}
return null
})
const defaultCurrencySymbol = computed<string>(() => {
const curr = defaultCurrency.value as Record<string, unknown> | null
return (curr?.symbol as string) ?? '$'
})
watch(
() => formData.value.items,
() => setDiscount(),
{ deep: true },
)
const totalDiscount = computed<number>({
get: () => formData.value.discount,
set: (newValue: number) => {
formData.value.discount = newValue
setDiscount()
},
})
const taxes = computed<DocumentTax[]>({
get: () => formData.value.taxes,
set: (value: DocumentTax[]) => {
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes = value
})
},
})
interface AggregatedTax {
tax_type_id: number
amount: number
percent: number | null
name: string
calculation_type: string | null
fixed_amount: number
}
const itemWiseTaxes = computed<AggregatedTax[]>(() => {
const result: AggregatedTax[] = []
formData.value.items.forEach((item: DocumentItem) => {
if (item.taxes) {
item.taxes.forEach((tax: Partial<DocumentTax>) => {
const found = result.find((_tax) => _tax.tax_type_id === tax.tax_type_id)
if (found) {
found.amount += tax.amount ?? 0
} else if (tax.tax_type_id) {
result.push({
tax_type_id: tax.tax_type_id,
amount: Math.round(tax.amount ?? 0),
percent: tax.percent ?? null,
name: tax.name ?? '',
calculation_type: tax.calculation_type ?? null,
fixed_amount: tax.fixed_amount ?? 0,
})
}
})
}
})
return result
})
function setDiscount(): void {
const newValue = formData.value.discount
if (formData.value.discount_type === 'percentage') {
formData.value.discount_val = Math.round((props.store.getSubTotal * newValue) / 100)
return
}
formData.value.discount_val = Math.round(newValue * 100)
}
function selectFixed(): void {
if (formData.value.discount_type === 'fixed') return
formData.value.discount_val = Math.round(formData.value.discount * 100)
formData.value.discount_type = 'fixed'
}
function selectPercentage(): void {
if (formData.value.discount_type === 'percentage') return
const val = Math.round(formData.value.discount * 100) / 100
formData.value.discount_val = Math.round((props.store.getSubTotal * val) / 100)
formData.value.discount_type = 'percentage'
}
function onSelectTax(selectedTax: TaxType): void {
let amount = 0
if (
selectedTax.calculation_type === 'percentage' &&
props.store.getSubtotalWithDiscount &&
selectedTax.percent
) {
amount = Math.round(
(props.store.getSubtotalWithDiscount * selectedTax.percent) / 100,
)
} else if (selectedTax.calculation_type === 'fixed') {
amount = selectedTax.fixed_amount
}
const data: DocumentTax = {
id: crypto.randomUUID(),
name: selectedTax.name,
percent: selectedTax.percent,
tax_type_id: selectedTax.id,
amount,
calculation_type: selectedTax.calculation_type,
fixed_amount: selectedTax.fixed_amount,
compound_tax: selectedTax.compound_tax ?? false,
}
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes.push({ ...data })
})
}
function updateTax(data: DocumentTax): void {
const tax = formData.value.taxes.find((t: DocumentTax) => t.id === data.id)
if (tax) {
Object.assign(tax, { ...data })
}
}
function removeTax(id: number | string): void {
const index = formData.value.taxes.findIndex((tax: DocumentTax) => tax.id === id)
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes.splice(index, 1)
})
}
</script>