mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 17:24:10 +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>
264 lines
7.4 KiB
Vue
264 lines
7.4 KiB
Vue
<template>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center text-base" style="flex: 4">
|
|
<label class="pr-2 mb-0" align="right">
|
|
{{ $t('invoices.item.tax') }}
|
|
</label>
|
|
|
|
<BaseMultiselect
|
|
v-model="selectedTax"
|
|
value-prop="id"
|
|
:options="filteredTypes"
|
|
:placeholder="$t('general.select_a_tax')"
|
|
open-direction="top"
|
|
track-by="name"
|
|
searchable
|
|
object
|
|
label="name"
|
|
@update:modelValue="onSelectTax"
|
|
>
|
|
<template #singlelabel="{ value }">
|
|
<div class="absolute left-3.5">
|
|
{{ value.name }} -
|
|
<template v-if="value.calculation_type === 'fixed'">
|
|
<BaseFormatMoney :amount="value.fixed_amount" :currency="currency" />
|
|
</template>
|
|
<template v-else>
|
|
{{ value.percent }} %
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<template #option="{ option }">
|
|
{{ option.name }} -
|
|
<template v-if="option.calculation_type === 'fixed'">
|
|
<BaseFormatMoney :amount="option.fixed_amount" :currency="currency" />
|
|
</template>
|
|
<template v-else>
|
|
{{ option.percent }} %
|
|
</template>
|
|
</template>
|
|
|
|
<template v-if="canAddTax" #action>
|
|
<button
|
|
type="button"
|
|
class="flex items-center justify-center w-full px-2 py-2 bg-surface-muted border-none outline-hidden cursor-pointer"
|
|
@click="openTaxModal"
|
|
>
|
|
<BaseIcon name="CheckCircleIcon" class="h-5 text-primary-400" />
|
|
<label class="ml-2 text-sm leading-none cursor-pointer text-primary-400">
|
|
{{ $t('invoices.add_new_tax') }}
|
|
</label>
|
|
</button>
|
|
</template>
|
|
</BaseMultiselect>
|
|
<br />
|
|
</div>
|
|
|
|
<div class="text-sm text-right" style="flex: 3">
|
|
<BaseFormatMoney :amount="taxAmount" :currency="currency" />
|
|
</div>
|
|
|
|
<div class="flex items-center justify-center w-6 h-10 mx-2 cursor-pointer">
|
|
<BaseIcon
|
|
v-if="taxes.length && index !== taxes.length - 1"
|
|
name="TrashIcon"
|
|
class="h-5 text-body cursor-pointer"
|
|
@click="removeTax(index)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, reactive, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { TaxType } from '../../../types/domain/tax'
|
|
import type { Currency } from '../../../types/domain/currency'
|
|
import type { DocumentFormData, DocumentTax } from './use-document-calculations'
|
|
|
|
interface Props {
|
|
ability: string
|
|
store: Record<string, unknown>
|
|
storeProp: string
|
|
itemIndex: number
|
|
index: number
|
|
taxData: DocumentTax
|
|
taxes: DocumentTax[]
|
|
total: number
|
|
totalTax: number
|
|
discountedTotal: number
|
|
currency: Currency | Record<string, unknown>
|
|
updateItems: () => void
|
|
discount?: number
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'remove', index: number): void
|
|
(e: 'update', payload: { index: number; item: DocumentTax }): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
ability: '',
|
|
discount: 0,
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
// We assume these stores are available globally or injected
|
|
// In the v2 arch, we'll use a lighter approach
|
|
const taxTypes = computed<TaxType[]>(() => {
|
|
// Access taxTypeStore through the store's taxTypes or a global store
|
|
return (window as Record<string, unknown>).__taxTypes as TaxType[] ?? []
|
|
})
|
|
|
|
const canAddTax = computed(() => {
|
|
return (window as Record<string, unknown>).__userHasAbility?.(props.ability) ?? false
|
|
})
|
|
|
|
const selectedTax = ref<TaxType | null>(null)
|
|
const localTax = reactive<DocumentTax>({ ...props.taxData })
|
|
|
|
const storeData = computed(() => props.store[props.storeProp] as DocumentFormData)
|
|
|
|
const filteredTypes = computed<(TaxType & { disabled?: boolean })[]>(() => {
|
|
const clonedTypes = taxTypes.value.map((a) => ({ ...a, disabled: false }))
|
|
|
|
return clonedTypes.map((taxType) => {
|
|
const found = props.taxes.find((tax) => tax.tax_type_id === taxType.id)
|
|
taxType.disabled = !!found
|
|
return taxType
|
|
})
|
|
})
|
|
|
|
const taxAmount = computed<number>(() => {
|
|
if (localTax.calculation_type === 'fixed') {
|
|
return localTax.fixed_amount
|
|
}
|
|
|
|
if (props.discountedTotal) {
|
|
const taxPerItemEnabled = storeData.value.tax_per_item === 'YES'
|
|
const discountPerItemEnabled = storeData.value.discount_per_item === 'YES'
|
|
|
|
if (taxPerItemEnabled && !discountPerItemEnabled) {
|
|
return getTaxAmount()
|
|
}
|
|
if (storeData.value.tax_included) {
|
|
return Math.round(
|
|
props.discountedTotal -
|
|
props.discountedTotal / (1 + (localTax.percent ?? 0) / 100),
|
|
)
|
|
}
|
|
return Math.round((props.discountedTotal * (localTax.percent ?? 0)) / 100)
|
|
}
|
|
return 0
|
|
})
|
|
|
|
watch(
|
|
() => props.discountedTotal,
|
|
() => updateRowTax(),
|
|
)
|
|
|
|
watch(
|
|
() => props.totalTax,
|
|
() => updateRowTax(),
|
|
)
|
|
|
|
watch(
|
|
() => taxAmount.value,
|
|
() => updateRowTax(),
|
|
)
|
|
|
|
// Initialize selected tax if editing
|
|
if (props.taxData.tax_type_id > 0) {
|
|
selectedTax.value =
|
|
taxTypes.value.find((_type) => _type.id === props.taxData.tax_type_id) ?? null
|
|
}
|
|
|
|
updateRowTax()
|
|
|
|
function onSelectTax(val: TaxType): void {
|
|
localTax.calculation_type = val.calculation_type
|
|
localTax.percent = val.calculation_type === 'percentage' ? val.percent : null
|
|
localTax.fixed_amount =
|
|
val.calculation_type === 'fixed' ? val.fixed_amount : 0
|
|
localTax.tax_type_id = val.id
|
|
localTax.name = val.name
|
|
|
|
updateRowTax()
|
|
}
|
|
|
|
function updateRowTax(): void {
|
|
if (localTax.tax_type_id === 0) {
|
|
return
|
|
}
|
|
|
|
emit('update', {
|
|
index: props.index,
|
|
item: {
|
|
...localTax,
|
|
amount: taxAmount.value,
|
|
},
|
|
})
|
|
}
|
|
|
|
function openTaxModal(): void {
|
|
// Modal integration - emit event or use modal store
|
|
const modalStore = (window as Record<string, unknown>).__modalStore as
|
|
| { openModal: (opts: Record<string, unknown>) => void }
|
|
| undefined
|
|
modalStore?.openModal({
|
|
title: t('settings.tax_types.add_tax'),
|
|
componentName: 'TaxTypeModal',
|
|
data: { itemIndex: props.itemIndex, taxIndex: props.index },
|
|
size: 'sm',
|
|
})
|
|
}
|
|
|
|
function removeTax(index: number): void {
|
|
const store = props.store as Record<string, Record<string, unknown>>
|
|
const formData = store[props.storeProp] as DocumentFormData
|
|
formData.items[props.itemIndex].taxes?.splice(index, 1)
|
|
const item = formData.items[props.itemIndex]
|
|
item.tax = 0
|
|
item.totalTax = 0
|
|
}
|
|
|
|
function getTaxAmount(): number {
|
|
if (localTax.calculation_type === 'fixed') {
|
|
return localTax.fixed_amount
|
|
}
|
|
|
|
let itemsTotal = 0
|
|
let discount = 0
|
|
const itemTotal = props.discountedTotal
|
|
const modelDiscount = storeData.value.discount ?? 0
|
|
const type = storeData.value.discount_type
|
|
let discountedTotal = props.discountedTotal
|
|
|
|
if (modelDiscount > 0) {
|
|
storeData.value.items.forEach((item) => {
|
|
itemsTotal += item.total ?? 0
|
|
})
|
|
const proportion = parseFloat((itemTotal / itemsTotal).toFixed(2))
|
|
discount =
|
|
type === 'fixed'
|
|
? modelDiscount * 100
|
|
: (itemsTotal * modelDiscount) / 100
|
|
const itemDiscount = Math.round(discount * proportion)
|
|
discountedTotal = itemTotal - itemDiscount
|
|
}
|
|
|
|
if (storeData.value.tax_included) {
|
|
return Math.round(
|
|
discountedTotal -
|
|
discountedTotal / (1 + (localTax.percent ?? 0) / 100),
|
|
)
|
|
}
|
|
|
|
return Math.round((discountedTotal * (localTax.percent ?? 0)) / 100)
|
|
}
|
|
</script>
|