mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-18 02:34:08 +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:
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<tr class="box-border bg-surface border-b border-line-light">
|
||||
<td colspan="5" class="p-0 text-left align-top">
|
||||
<table class="w-full">
|
||||
<colgroup>
|
||||
<col style="width: 40%; min-width: 280px" />
|
||||
<col style="width: 10%; min-width: 120px" />
|
||||
<col style="width: 15%; min-width: 120px" />
|
||||
<col
|
||||
v-if="formData.discount_per_item === 'YES'"
|
||||
style="width: 15%; min-width: 160px"
|
||||
/>
|
||||
<col style="width: 15%; min-width: 120px" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<!-- Item Name + Description -->
|
||||
<td class="px-5 py-4 text-left align-top">
|
||||
<div class="flex justify-start">
|
||||
<div
|
||||
class="flex items-center justify-center w-5 h-5 mt-2 mr-2 text-subtle cursor-move handle"
|
||||
>
|
||||
<DragIcon />
|
||||
</div>
|
||||
<BaseItemSelect
|
||||
type="Invoice"
|
||||
:item="itemData"
|
||||
:invalid="v$.name.$error"
|
||||
:invalid-description="v$.description.$error"
|
||||
:taxes="itemData.taxes"
|
||||
:index="index"
|
||||
:store-prop="storeProp"
|
||||
:store="store"
|
||||
@search="searchVal"
|
||||
@select="onSelectItem"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Quantity -->
|
||||
<td class="px-5 py-4 text-right align-top">
|
||||
<BaseInput
|
||||
v-model="quantity"
|
||||
:invalid="v$.quantity.$error"
|
||||
:content-loading="loading"
|
||||
type="number"
|
||||
small
|
||||
step="any"
|
||||
@change="syncItemToStore()"
|
||||
@input="v$.quantity.$touch()"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Price -->
|
||||
<td class="px-5 py-4 text-left align-top">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex-auto flex-fill bd-highlight">
|
||||
<div class="relative w-full">
|
||||
<BaseMoney
|
||||
:key="selectedCurrency?.id ?? 'default'"
|
||||
v-model="price"
|
||||
:invalid="v$.price.$error"
|
||||
:content-loading="loading"
|
||||
:currency="selectedCurrency"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Discount -->
|
||||
<td
|
||||
v-if="formData.discount_per_item === 'YES'"
|
||||
class="px-5 py-4 text-left align-top"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex" style="width: 120px" role="group">
|
||||
<BaseInput
|
||||
v-model="discount"
|
||||
:invalid="v$.discount_val.$error"
|
||||
:content-loading="loading"
|
||||
class="border-r-0 focus:border-r-2 rounded-tr-sm rounded-br-sm h-[38px]"
|
||||
/>
|
||||
<BaseDropdown position="bottom-end">
|
||||
<template #activator>
|
||||
<BaseButton
|
||||
:content-loading="loading"
|
||||
class="rounded-tr-md rounded-br-md !p-2 rounded-none"
|
||||
type="button"
|
||||
variant="white"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{{
|
||||
itemData.discount_type === 'fixed'
|
||||
? currencySymbol
|
||||
: '%'
|
||||
}}
|
||||
<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>
|
||||
</td>
|
||||
|
||||
<!-- Amount -->
|
||||
<td class="px-5 py-4 text-right align-top">
|
||||
<div class="flex items-center justify-end text-sm">
|
||||
<span>
|
||||
<BaseContentPlaceholders v-if="loading">
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<BaseFormatMoney
|
||||
v-else
|
||||
:amount="total"
|
||||
:currency="selectedCurrency"
|
||||
/>
|
||||
<span
|
||||
v-if="showBaseCurrencyEquivalent"
|
||||
class="block text-xs text-muted mt-1"
|
||||
>
|
||||
<BaseFormatMoney
|
||||
:amount="baseCurrencyTotal"
|
||||
:currency="companyCurrency"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex items-center justify-center w-6 h-10 mx-2">
|
||||
<BaseIcon
|
||||
v-if="showRemoveButton"
|
||||
class="h-5 text-body cursor-pointer"
|
||||
name="TrashIcon"
|
||||
@click="store.removeItem(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Per-item taxes -->
|
||||
<tr v-if="formData.tax_per_item === 'YES'">
|
||||
<td class="px-5 py-4 text-left align-top" />
|
||||
<td colspan="4" class="px-5 py-4 text-left align-top">
|
||||
<BaseContentPlaceholders v-if="loading">
|
||||
<BaseContentPlaceholdersText
|
||||
:lines="1"
|
||||
class="w-24 h-8 border border-line-light rounded-md"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<DocumentItemRowTax
|
||||
v-for="(tax, taxIndex) in itemData.taxes"
|
||||
v-else
|
||||
:key="tax.id"
|
||||
:index="taxIndex"
|
||||
:item-index="index"
|
||||
:tax-data="tax"
|
||||
:taxes="itemData.taxes ?? []"
|
||||
:discounted-total="total"
|
||||
:total-tax="totalSimpleTax"
|
||||
:total="subtotal"
|
||||
:currency="currency"
|
||||
:update-items="syncItemToStore"
|
||||
:ability="'create-invoice'"
|
||||
:store="store"
|
||||
:store-prop="storeProp"
|
||||
:discount="discount"
|
||||
@update="updateTax"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, between, maxLength, helpers, minValue } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useCompanyStore } from '../../../stores/company.store'
|
||||
import DocumentItemRowTax from './DocumentItemRowTax.vue'
|
||||
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
|
||||
import { generateClientId } from '../../../utils'
|
||||
import type { Currency } from '../../../types/domain/currency'
|
||||
import type { DocumentItem, DocumentFormData, DocumentTax } from './use-document-calculations'
|
||||
|
||||
interface Props {
|
||||
store: Record<string, unknown> & {
|
||||
removeItem: (index: number) => void
|
||||
updateItem: (data: Record<string, unknown>) => void
|
||||
$patch: (fn: (state: Record<string, unknown>) => void) => void
|
||||
}
|
||||
storeProp: string
|
||||
itemData: DocumentItem
|
||||
index: number
|
||||
type?: string
|
||||
loading?: boolean
|
||||
currency: Currency | Record<string, unknown>
|
||||
invoiceItems: DocumentItem[]
|
||||
itemValidationScope?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update', data: Record<string, unknown>): void
|
||||
(e: 'remove', index: number): void
|
||||
(e: 'itemValidate', valid: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: '',
|
||||
loading: false,
|
||||
itemValidationScope: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
const formData = computed<DocumentFormData>(() => {
|
||||
return props.store[props.storeProp] as DocumentFormData
|
||||
})
|
||||
|
||||
const currencySymbol = computed<string>(() => {
|
||||
const curr = props.currency as Record<string, unknown>
|
||||
return (curr?.symbol as string) ?? '$'
|
||||
})
|
||||
|
||||
const quantity = computed<number>({
|
||||
get: () => props.itemData.quantity,
|
||||
set: (newValue: number) => {
|
||||
updateItemAttribute('quantity', parseFloat(String(newValue)))
|
||||
},
|
||||
})
|
||||
|
||||
const price = computed<number>({
|
||||
get: () => props.itemData.price / 100,
|
||||
set: (newValue: number) => {
|
||||
const priceInCents = Math.round(newValue * 100)
|
||||
updateItemAttribute('price', priceInCents)
|
||||
setDiscount()
|
||||
},
|
||||
})
|
||||
|
||||
const subtotal = computed<number>(() => {
|
||||
return Math.round(props.itemData.price * props.itemData.quantity)
|
||||
})
|
||||
|
||||
const discount = computed<number>({
|
||||
get: () => props.itemData.discount,
|
||||
set: (newValue: number) => {
|
||||
updateItemAttribute('discount', newValue)
|
||||
setDiscount()
|
||||
},
|
||||
})
|
||||
|
||||
const total = computed<number>(() => {
|
||||
return subtotal.value - props.itemData.discount_val
|
||||
})
|
||||
|
||||
const selectedCurrency = computed(() => {
|
||||
if (props.currency) {
|
||||
return props.currency
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const showRemoveButton = computed<boolean>(() => {
|
||||
return formData.value.items.length > 1
|
||||
})
|
||||
|
||||
const totalSimpleTax = computed<number>(() => {
|
||||
const taxes = props.itemData.taxes ?? []
|
||||
return Math.round(
|
||||
taxes.reduce((sum: number, tax: Partial<DocumentTax>) => {
|
||||
return sum + (tax.amount ?? 0)
|
||||
}, 0),
|
||||
)
|
||||
})
|
||||
|
||||
const totalTax = computed<number>(() => totalSimpleTax.value)
|
||||
|
||||
const companyCurrency = computed(() => companyStore.selectedCompanyCurrency)
|
||||
|
||||
const showBaseCurrencyEquivalent = computed<boolean>(() => {
|
||||
return !!(formData.value.exchange_rate && (props.store as Record<string, unknown>).showExchangeRate)
|
||||
})
|
||||
|
||||
const baseCurrencyTotal = computed<number>(() => {
|
||||
if (!formData.value.exchange_rate) return 0
|
||||
return Math.round(total.value * Number(formData.value.exchange_rate))
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
quantity: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
maxLength: helpers.withMessage(t('validation.amount_maxlength'), maxLength(20)),
|
||||
},
|
||||
price: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
maxLength: helpers.withMessage(t('validation.price_maxlength'), maxLength(20)),
|
||||
},
|
||||
discount_val: {
|
||||
between: helpers.withMessage(
|
||||
t('validation.discount_maxlength'),
|
||||
between(
|
||||
0,
|
||||
computed(() => Math.abs(subtotal.value)),
|
||||
),
|
||||
),
|
||||
},
|
||||
description: {
|
||||
maxLength: helpers.withMessage(t('validation.notes_maxlength'), maxLength(65000)),
|
||||
},
|
||||
}
|
||||
|
||||
const v$ = useVuelidate(
|
||||
rules,
|
||||
computed(() => formData.value.items[props.index]),
|
||||
{ $scope: props.itemValidationScope },
|
||||
)
|
||||
|
||||
function updateTax(data: { index: number; item: DocumentTax }): void {
|
||||
props.store.$patch((state: Record<string, unknown>) => {
|
||||
const form = state[props.storeProp] as DocumentFormData
|
||||
form.items[props.index].taxes![data.index] = data.item
|
||||
})
|
||||
|
||||
const itemTaxes = props.itemData.taxes ?? []
|
||||
const lastTax = itemTaxes[itemTaxes.length - 1]
|
||||
|
||||
if (lastTax?.tax_type_id !== 0) {
|
||||
props.store.$patch((state: Record<string, unknown>) => {
|
||||
const form = state[props.storeProp] as DocumentFormData
|
||||
form.items[props.index].taxes!.push({
|
||||
id: generateClientId(),
|
||||
tax_type_id: 0,
|
||||
name: '',
|
||||
amount: 0,
|
||||
percent: null,
|
||||
calculation_type: null,
|
||||
fixed_amount: 0,
|
||||
compound_tax: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
syncItemToStore()
|
||||
}
|
||||
|
||||
function setDiscount(): void {
|
||||
const newValue = formData.value.items[props.index].discount
|
||||
const absoluteSubtotal = Math.abs(subtotal.value)
|
||||
|
||||
if (props.itemData.discount_type === 'percentage') {
|
||||
updateItemAttribute('discount_val', Math.round((absoluteSubtotal * newValue) / 100))
|
||||
} else {
|
||||
updateItemAttribute(
|
||||
'discount_val',
|
||||
Math.min(Math.round(newValue * 100), absoluteSubtotal),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function searchVal(val: string): void {
|
||||
updateItemAttribute('name', val)
|
||||
}
|
||||
|
||||
function onSelectItem(itm: Record<string, unknown>): void {
|
||||
props.store.$patch((state: Record<string, unknown>) => {
|
||||
const form = state[props.storeProp] as DocumentFormData
|
||||
const item = form.items[props.index]
|
||||
item.name = itm.name as string
|
||||
item.price = itm.price as number
|
||||
item.item_id = itm.id as number
|
||||
item.description = (itm.description as string | null) ?? null
|
||||
|
||||
if (itm.unit) {
|
||||
item.unit_name = (itm.unit as Record<string, string>).name
|
||||
}
|
||||
|
||||
if (form.tax_per_item === 'YES' && itm.taxes) {
|
||||
let idx = 0
|
||||
;(itm.taxes as DocumentTax[]).forEach((tax) => {
|
||||
updateTax({ index: idx, item: { ...tax } })
|
||||
idx++
|
||||
})
|
||||
}
|
||||
|
||||
if (form.exchange_rate) {
|
||||
item.price = Math.round(item.price / form.exchange_rate)
|
||||
}
|
||||
})
|
||||
|
||||
syncItemToStore()
|
||||
}
|
||||
|
||||
function selectFixed(): void {
|
||||
if (props.itemData.discount_type === 'fixed') return
|
||||
updateItemAttribute('discount_val', Math.round(props.itemData.discount * 100))
|
||||
updateItemAttribute('discount_type', 'fixed')
|
||||
}
|
||||
|
||||
function selectPercentage(): void {
|
||||
if (props.itemData.discount_type === 'percentage') return
|
||||
updateItemAttribute('discount_val', (subtotal.value * props.itemData.discount) / 100)
|
||||
updateItemAttribute('discount_type', 'percentage')
|
||||
}
|
||||
|
||||
function syncItemToStore(): void {
|
||||
const itemTaxes = formData.value.items?.[props.index]?.taxes ?? []
|
||||
|
||||
const data = {
|
||||
...formData.value.items[props.index],
|
||||
index: props.index,
|
||||
total: total.value,
|
||||
sub_total: subtotal.value,
|
||||
totalSimpleTax: totalSimpleTax.value,
|
||||
totalTax: totalTax.value,
|
||||
tax: totalTax.value,
|
||||
taxes: [...itemTaxes],
|
||||
tax_type_ids: itemTaxes.flatMap((tax) =>
|
||||
tax.tax_type_id ? [tax.tax_type_id] : [],
|
||||
),
|
||||
}
|
||||
|
||||
props.store.updateItem(data)
|
||||
}
|
||||
|
||||
function updateItemAttribute(attribute: string, value: unknown): void {
|
||||
props.store.$patch((state: Record<string, unknown>) => {
|
||||
const form = state[props.storeProp] as DocumentFormData
|
||||
;(form.items[props.index] as Record<string, unknown>)[attribute] = value
|
||||
})
|
||||
|
||||
syncItemToStore()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,261 @@
|
||||
<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 { useModalStore } from '../../../stores/modal.store'
|
||||
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()
|
||||
const modalStore = useModalStore()
|
||||
|
||||
// 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 {
|
||||
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>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="rounded-xl border border-line-light shadow bg-surface">
|
||||
<!-- Tax Included Toggle -->
|
||||
<div
|
||||
v-if="taxIncludedSetting === 'YES'"
|
||||
class="flex items-center justify-end w-full px-6 text-base border-b border-line-light cursor-pointer text-primary-400 bg-surface"
|
||||
>
|
||||
<BaseSwitchSection
|
||||
v-model="taxIncludedField"
|
||||
:title="$t('settings.tax_types.tax_included')"
|
||||
:store="store"
|
||||
:store-prop="storeProp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<table class="text-center item-table min-w-full">
|
||||
<colgroup>
|
||||
<col style="width: 40%; min-width: 280px" />
|
||||
<col style="width: 10%; min-width: 120px" />
|
||||
<col style="width: 15%; min-width: 120px" />
|
||||
<col
|
||||
v-if="formData.discount_per_item === 'YES'"
|
||||
style="width: 15%; min-width: 160px"
|
||||
/>
|
||||
<col style="width: 15%; min-width: 120px" />
|
||||
</colgroup>
|
||||
|
||||
<thead class="bg-surface-secondary border-b border-line-light">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body">
|
||||
<BaseContentPlaceholders v-if="isLoading">
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
|
||||
</BaseContentPlaceholders>
|
||||
<span v-else class="pl-7">
|
||||
{{ $t('items.item', 2) }}
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-right text-body">
|
||||
<BaseContentPlaceholders v-if="isLoading">
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
|
||||
</BaseContentPlaceholders>
|
||||
<span v-else>
|
||||
{{ $t('invoices.item.quantity') }}
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body">
|
||||
<BaseContentPlaceholders v-if="isLoading">
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
|
||||
</BaseContentPlaceholders>
|
||||
<span v-else>
|
||||
{{ $t('invoices.item.price') }}
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
v-if="formData.discount_per_item === 'YES'"
|
||||
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body"
|
||||
>
|
||||
<BaseContentPlaceholders v-if="isLoading">
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
|
||||
</BaseContentPlaceholders>
|
||||
<span v-else>
|
||||
{{ $t('invoices.item.discount') }}
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-right text-body">
|
||||
<BaseContentPlaceholders v-if="isLoading">
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
|
||||
</BaseContentPlaceholders>
|
||||
<span v-else class="pr-10 column-heading">
|
||||
{{ $t('invoices.item.amount') }}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<draggable
|
||||
v-model="formData.items"
|
||||
item-key="id"
|
||||
tag="tbody"
|
||||
handle=".handle"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<DocumentItemRow
|
||||
:key="element.id"
|
||||
:index="index"
|
||||
:item-data="element"
|
||||
:loading="isLoading"
|
||||
:currency="defaultCurrency"
|
||||
:item-validation-scope="itemValidationScope"
|
||||
:invoice-items="formData.items"
|
||||
:store="store"
|
||||
:store-prop="storeProp"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</table>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center w-full px-6 py-3 text-base border-t border-line-light cursor-pointer text-primary-400 hover:bg-primary-100"
|
||||
@click="store.addItem()"
|
||||
>
|
||||
<BaseIcon name="PlusCircleIcon" class="mr-2" />
|
||||
{{ $t('general.add_new_item') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import DocumentItemRow from './DocumentItemRow.vue'
|
||||
import type { Currency } from '../../../types/domain/currency'
|
||||
import type { DocumentFormData } from './use-document-calculations'
|
||||
|
||||
interface Props {
|
||||
store: Record<string, unknown> & {
|
||||
addItem: () => void
|
||||
}
|
||||
storeProp: string
|
||||
currency: Currency | Record<string, unknown> | string | null
|
||||
isLoading?: boolean
|
||||
itemValidationScope?: string
|
||||
taxIncludedSetting?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
itemValidationScope: '',
|
||||
taxIncludedSetting: 'NO',
|
||||
})
|
||||
|
||||
const formData = computed<DocumentFormData>(() => {
|
||||
return props.store[props.storeProp] as DocumentFormData
|
||||
})
|
||||
|
||||
const defaultCurrency = computed(() => {
|
||||
if (props.currency) {
|
||||
return props.currency
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const taxIncludedField = computed<boolean>({
|
||||
get: () => {
|
||||
return !!formData.value.tax_included
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
formData.value.tax_included = value
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<div class="z-20 text-sm font-semibold leading-5 text-primary-400 float-right">
|
||||
<NoteSelectPopup :type="type" @select="onSelectNote" />
|
||||
</div>
|
||||
<label class="text-heading font-medium mb-4 text-sm">
|
||||
{{ $t('invoices.notes') }}
|
||||
</label>
|
||||
<BaseCustomInput
|
||||
v-model="notes"
|
||||
:content-loading="contentLoading"
|
||||
:fields="fields"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import NoteSelectPopup from './NoteSelectPopup.vue'
|
||||
import type { DocumentFormData } from './use-document-calculations'
|
||||
import type { NoteType } from '../../../types/domain/note'
|
||||
|
||||
interface Props {
|
||||
store: Record<string, unknown>
|
||||
storeProp: string
|
||||
fields: Record<string, unknown> | null
|
||||
type: NoteType | string | null
|
||||
contentLoading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fields: null,
|
||||
type: null,
|
||||
contentLoading: false,
|
||||
})
|
||||
|
||||
const formData = computed<DocumentFormData>(() => {
|
||||
return props.store[props.storeProp] as DocumentFormData
|
||||
})
|
||||
|
||||
const notes = computed<string | null>({
|
||||
get: () => formData.value.notes,
|
||||
set: (value: string | null) => {
|
||||
formData.value.notes = value
|
||||
},
|
||||
})
|
||||
|
||||
function onSelectNote(data: { notes: string }): void {
|
||||
formData.value.notes = String(data.notes)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,446 @@
|
||||
<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"
|
||||
:tax-types="availableTaxTypes"
|
||||
:company-currency="companyCurrency"
|
||||
:can-create-tax-type="canCreateTaxType"
|
||||
@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>
|
||||
|
||||
<!-- Base currency equivalent -->
|
||||
<div v-if="showBaseCurrencyEquivalent" class="flex items-center justify-end w-full mt-1">
|
||||
<label class="text-xs text-muted">
|
||||
≈ <BaseFormatMoney :amount="baseCurrencyGrandTotal" :currency="companyCurrency" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import TaxSelectPopup from './TaxSelectPopup.vue'
|
||||
import { useCompanyStore } from '../../../stores/company.store'
|
||||
import { useUserStore } from '../../../stores/user.store'
|
||||
import { taxTypeService } from '../../../api/services/tax-type.service'
|
||||
import { generateClientId } from '../../../utils'
|
||||
import { ABILITIES } from '../../../config/abilities'
|
||||
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 companyStore = useCompanyStore()
|
||||
const userStore = useUserStore()
|
||||
const taxModal = ref<HTMLElement | null>(null)
|
||||
const availableTaxTypes = ref<TaxType[]>([])
|
||||
|
||||
const canCreateTaxType = computed<boolean>(() => {
|
||||
return userStore.hasAbilities(ABILITIES.CREATE_TAX_TYPE)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await taxTypeService.list({ limit: 'all' as unknown as number })
|
||||
availableTaxTypes.value = response.data
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
})
|
||||
|
||||
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) ?? '$'
|
||||
})
|
||||
|
||||
const companyCurrency = computed(() => companyStore.selectedCompanyCurrency)
|
||||
|
||||
const showBaseCurrencyEquivalent = computed<boolean>(() => {
|
||||
return !!(formData.value.exchange_rate && (props.store as Record<string, unknown>).showExchangeRate)
|
||||
})
|
||||
|
||||
const baseCurrencyGrandTotal = computed<number>(() => {
|
||||
if (!formData.value.exchange_rate) return 0
|
||||
return Math.round(props.store.getTotal * Number(formData.value.exchange_rate))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => formData.value.items,
|
||||
() => {
|
||||
setDiscount()
|
||||
recalculateGlobalTaxes()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const totalDiscount = computed<number>({
|
||||
get: () => formData.value.discount,
|
||||
set: (newValue: number) => {
|
||||
formData.value.discount = newValue
|
||||
setDiscount()
|
||||
recalculateGlobalTaxes()
|
||||
},
|
||||
})
|
||||
|
||||
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 recalculateGlobalTaxes(): void {
|
||||
if (formData.value.tax_per_item === 'YES') return
|
||||
|
||||
const subtotalWithDiscount = props.store.getSubtotalWithDiscount
|
||||
formData.value.taxes.forEach((tax: DocumentTax) => {
|
||||
if (tax.calculation_type === 'percentage' && tax.percent) {
|
||||
tax.amount = Math.round((subtotalWithDiscount * tax.percent) / 100)
|
||||
}
|
||||
// Fixed taxes keep their amount as-is
|
||||
})
|
||||
}
|
||||
|
||||
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: generateClientId(),
|
||||
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>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<BaseInputGroup
|
||||
v-if="showExchangeRate && selectedCurrency"
|
||||
:content-loading="isFetching && !isEdit"
|
||||
:label="$t('settings.exchange_rate.exchange_rate')"
|
||||
:error="
|
||||
v.exchange_rate.$error && v.exchange_rate.$errors[0].$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<template #labelRight>
|
||||
<div v-if="hasActiveProvider && isEdit">
|
||||
<BaseIcon
|
||||
v-tooltip="{ content: 'Fetch Latest Exchange rate' }"
|
||||
name="ArrowPathIcon"
|
||||
:class="`h-4 w-4 text-primary-500 cursor-pointer outline-hidden ${
|
||||
isFetching
|
||||
? ' animate-spin rotate-180 cursor-not-allowed pointer-events-none '
|
||||
: ''
|
||||
}`"
|
||||
@click="getCurrentExchangeRate(customerCurrency)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<BaseInput
|
||||
v-model="exchangeRate"
|
||||
:content-loading="isFetching && !isEdit"
|
||||
:addon="`1 ${selectedCurrency.code} =`"
|
||||
:disabled="isFetching"
|
||||
@input="v.exchange_rate.$touch()"
|
||||
>
|
||||
<template #right>
|
||||
<span class="text-muted sm:text-sm">
|
||||
{{ companyCurrency?.code ?? '' }}
|
||||
</span>
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
<span class="text-subtle text-xs mt-2 font-light">
|
||||
{{
|
||||
$t('settings.exchange_rate.exchange_help_text', {
|
||||
currency: selectedCurrency.code,
|
||||
baseCurrency: companyCurrency?.code ?? '',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { exchangeRateService } from '../../../api/services/exchange-rate.service'
|
||||
import { useCompanyStore } from '../../../stores/company.store'
|
||||
import { useGlobalStore } from '../../../stores/global.store'
|
||||
import type { Currency } from '../../../types/domain/currency'
|
||||
import type { DocumentFormData } from './use-document-calculations'
|
||||
|
||||
interface Props {
|
||||
v: Record<string, { $error: boolean; $errors: Array<{ $message: string }>; $touch: () => void }>
|
||||
isLoading?: boolean
|
||||
store: Record<string, unknown> & {
|
||||
showExchangeRate: boolean
|
||||
}
|
||||
storeProp: string
|
||||
isEdit?: boolean
|
||||
customerCurrency?: number | string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
isEdit: false,
|
||||
customerCurrency: null,
|
||||
})
|
||||
|
||||
const companyStore = useCompanyStore()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const hasActiveProvider = ref<boolean>(false)
|
||||
const isFetching = ref<boolean>(false)
|
||||
|
||||
onMounted(() => {
|
||||
globalStore.fetchCurrencies()
|
||||
})
|
||||
|
||||
const formData = computed<DocumentFormData>(() => {
|
||||
return props.store[props.storeProp] as DocumentFormData
|
||||
})
|
||||
|
||||
const showExchangeRate = computed<boolean>(() => {
|
||||
return props.store.showExchangeRate
|
||||
})
|
||||
|
||||
const exchangeRate = computed<number | null | string>({
|
||||
get: () => formData.value.exchange_rate ?? null,
|
||||
set: (value) => {
|
||||
formData.value.exchange_rate = value as number
|
||||
},
|
||||
})
|
||||
|
||||
const companyCurrency = computed<Currency | null>(() => {
|
||||
return companyStore.selectedCompanyCurrency
|
||||
})
|
||||
|
||||
const selectedCurrency = computed<Currency | null>(() => {
|
||||
return (
|
||||
globalStore.currencies.find((c: Currency) => c.id === formData.value.currency_id) ?? null
|
||||
)
|
||||
})
|
||||
|
||||
const isCurrencyDifferent = computed<boolean>(() => {
|
||||
return companyCurrency.value?.id !== props.customerCurrency
|
||||
})
|
||||
|
||||
watch(
|
||||
() => (formData.value as Record<string, unknown>).customer,
|
||||
(v) => {
|
||||
setCustomerCurrency(v as Record<string, unknown> | null)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => formData.value.currency_id,
|
||||
(v) => {
|
||||
onChangeCurrency(v)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.customerCurrency,
|
||||
(v) => {
|
||||
if (v && props.isEdit) {
|
||||
checkForActiveProvider()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function checkForActiveProvider(): void {
|
||||
if (isCurrencyDifferent.value && props.customerCurrency) {
|
||||
exchangeRateService
|
||||
.getActiveProvider(Number(props.customerCurrency))
|
||||
.then((res) => {
|
||||
hasActiveProvider.value = res.hasActiveProvider
|
||||
})
|
||||
.catch(() => {
|
||||
hasActiveProvider.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setCustomerCurrency(v: Record<string, unknown> | null): void {
|
||||
if (v) {
|
||||
const currency = v.currency as Currency | undefined
|
||||
if (currency) {
|
||||
formData.value.currency_id = currency.id
|
||||
}
|
||||
} else if (companyCurrency.value) {
|
||||
formData.value.currency_id = companyCurrency.value.id
|
||||
}
|
||||
}
|
||||
|
||||
async function onChangeCurrency(v: number | undefined): Promise<void> {
|
||||
if (v !== companyCurrency.value?.id) {
|
||||
if (!props.isEdit && v) {
|
||||
await getCurrentExchangeRate(v)
|
||||
}
|
||||
props.store.showExchangeRate = true
|
||||
} else {
|
||||
props.store.showExchangeRate = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentExchangeRate(v: number | string | null | undefined): Promise<void> {
|
||||
if (!v) return
|
||||
isFetching.value = true
|
||||
try {
|
||||
const res = await exchangeRateService.getRate(Number(v))
|
||||
formData.value.exchange_rate = res.exchangeRate
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
isFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.store.showExchangeRate = false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div>
|
||||
<NoteModal />
|
||||
<div class="w-full">
|
||||
<Popover v-slot="{ open: isOpen }">
|
||||
<PopoverButton
|
||||
v-if="canViewNotes"
|
||||
class="flex items-center z-10 font-medium text-primary-400 focus:outline-hidden focus:border-none"
|
||||
@click="fetchInitialData"
|
||||
>
|
||||
<BaseIcon name="PlusIcon" class="w-4 h-4 font-medium text-primary-400" />
|
||||
{{ $t('general.insert_note') }}
|
||||
</PopoverButton>
|
||||
|
||||
<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"
|
||||
>
|
||||
<PopoverPanel
|
||||
v-slot="{ close }"
|
||||
class="absolute z-20 px-4 mt-3 sm:px-0 w-screen max-w-full left-0 top-3"
|
||||
>
|
||||
<div class="overflow-hidden rounded-md shadow-lg ring-1 ring-black/5">
|
||||
<div class="relative grid bg-surface">
|
||||
<div class="relative p-4">
|
||||
<BaseInput
|
||||
v-model="textSearch"
|
||||
:placeholder="$t('general.search')"
|
||||
type="text"
|
||||
class="text-heading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredNotes.length > 0"
|
||||
class="relative flex flex-col overflow-auto list max-h-36"
|
||||
>
|
||||
<div
|
||||
v-for="(note, idx) in filteredNotes"
|
||||
:key="idx"
|
||||
tabindex="2"
|
||||
class="px-6 py-4 border-b border-line-default border-solid cursor-pointer hover:bg-surface-tertiary hover:cursor-pointer last:border-b-0"
|
||||
@click="selectNote(idx, close)"
|
||||
>
|
||||
<div class="flex justify-between px-2">
|
||||
<label
|
||||
class="m-0 text-base font-semibold leading-tight text-body cursor-pointer"
|
||||
>
|
||||
{{ note.name }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-center p-5 text-subtle">
|
||||
<label class="text-base text-muted">
|
||||
{{ $t('general.no_note_found') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canManageNotes"
|
||||
type="button"
|
||||
class="h-10 flex items-center justify-center w-full px-2 py-3 bg-surface-muted border-none outline-hidden"
|
||||
@click="openNoteModal"
|
||||
>
|
||||
<BaseIcon name="CheckCircleIcon" class="text-primary-400" />
|
||||
<label
|
||||
class="m-0 ml-3 text-sm leading-none cursor-pointer font-base text-primary-400"
|
||||
>
|
||||
{{ $t('settings.customization.notes.add_new_note') }}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</transition>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '../../../stores/modal.store'
|
||||
import { useUserStore } from '../../../stores/user.store'
|
||||
import { ABILITIES } from '../../../config/abilities'
|
||||
import NoteModal from '../../company/settings/components/NoteModal.vue'
|
||||
import type { Note } from '../../../types/domain/note'
|
||||
import { noteService } from '../../../api/services/note.service'
|
||||
|
||||
interface Props {
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', data: Note): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modalStore = useModalStore()
|
||||
const userStore = useUserStore()
|
||||
const textSearch = ref<string | null>(null)
|
||||
const notes = ref<Note[]>([])
|
||||
|
||||
const canViewNotes = computed<boolean>(() =>
|
||||
userStore.hasAbilities(ABILITIES.VIEW_NOTE),
|
||||
)
|
||||
|
||||
const canManageNotes = computed<boolean>(() =>
|
||||
userStore.hasAbilities(ABILITIES.MANAGE_NOTE),
|
||||
)
|
||||
|
||||
const filteredNotes = computed<Note[]>(() => {
|
||||
if (textSearch.value) {
|
||||
return notes.value.filter((el) =>
|
||||
el.name.toLowerCase().includes(textSearch.value!.toLowerCase()),
|
||||
)
|
||||
}
|
||||
return notes.value
|
||||
})
|
||||
|
||||
async function fetchInitialData(): Promise<void> {
|
||||
try {
|
||||
const response = await noteService.list({
|
||||
search: '',
|
||||
orderByField: '',
|
||||
orderBy: 'asc',
|
||||
})
|
||||
notes.value = (response as unknown as { data: Note[] }).data ?? []
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
function selectNote(index: number, close: () => void): void {
|
||||
emit('select', { ...notes.value[index] })
|
||||
textSearch.value = null
|
||||
close()
|
||||
}
|
||||
|
||||
function openNoteModal(): void {
|
||||
modalStore.openModal({
|
||||
title: t('settings.customization.notes.add_note'),
|
||||
componentName: 'NoteModal',
|
||||
size: 'lg',
|
||||
data: props.type,
|
||||
refreshData: () => fetchInitialData(),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
|
||||
interface ModalData {
|
||||
templates: Array<{ name: string; path?: string }>
|
||||
store: { setTemplate: (name: string) => void; isEdit?: boolean; [key: string]: unknown }
|
||||
storeProp: string
|
||||
isMarkAsDefault: boolean
|
||||
markAsDefaultDescription: string
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const selectedTemplate = ref<string>('')
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'SelectTemplate',
|
||||
)
|
||||
|
||||
const modalData = computed<ModalData | null>(() => {
|
||||
return modalStore.data as ModalData | null
|
||||
})
|
||||
|
||||
function setData(): void {
|
||||
if (!modalData.value) return
|
||||
|
||||
const currentName =
|
||||
(modalData.value.store[modalData.value.storeProp] as Record<string, unknown>)
|
||||
?.template_name as string | undefined
|
||||
|
||||
if (currentName) {
|
||||
selectedTemplate.value = currentName
|
||||
} else if (modalData.value.templates.length) {
|
||||
selectedTemplate.value = modalData.value.templates[0].name
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseTemplate(): Promise<void> {
|
||||
if (!modalData.value) return
|
||||
|
||||
modalData.value.store.setTemplate(selectedTemplate.value)
|
||||
|
||||
if (!modalData.value.store.isEdit && modalData.value.isMarkAsDefault) {
|
||||
if (modalData.value.storeProp === 'newEstimate') {
|
||||
await userStore.updateUserSettings({
|
||||
settings: {
|
||||
default_estimate_template: selectedTemplate.value,
|
||||
},
|
||||
})
|
||||
} else if (modalData.value.storeProp === 'newInvoice') {
|
||||
await userStore.updateUserSettings({
|
||||
settings: {
|
||||
default_invoice_template: selectedTemplate.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function getTickImage(): string {
|
||||
return new URL('$images/tick.png', import.meta.url).href
|
||||
}
|
||||
|
||||
function closeModal(): void {
|
||||
modalStore.closeModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="modalActive" @close="closeModal" @open="setData">
|
||||
<template #header>
|
||||
<div class="flex justify-between w-full">
|
||||
{{ modalStore.title }}
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="h-6 w-6 text-muted cursor-pointer"
|
||||
@click="closeModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="px-8 py-8 sm:p-6">
|
||||
<div
|
||||
v-if="modalData"
|
||||
class="grid grid-cols-3 gap-2 p-1 overflow-x-auto"
|
||||
>
|
||||
<div
|
||||
v-for="(template, index) in modalData.templates"
|
||||
:key="index"
|
||||
:class="{
|
||||
'border border-solid border-primary-500':
|
||||
selectedTemplate === template.name,
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
flex flex-col
|
||||
m-2
|
||||
border border-line-default border-solid
|
||||
cursor-pointer
|
||||
hover:border-primary-300
|
||||
"
|
||||
@click="selectedTemplate = template.name"
|
||||
>
|
||||
<img
|
||||
:src="template.path"
|
||||
:alt="template.name"
|
||||
class="w-full min-h-[100px]"
|
||||
/>
|
||||
<img
|
||||
v-if="selectedTemplate === template.name"
|
||||
:alt="template.name"
|
||||
class="absolute z-10 w-5 h-5 text-primary-500"
|
||||
style="top: -6px; right: -5px"
|
||||
:src="getTickImage()"
|
||||
/>
|
||||
<span
|
||||
:class="[
|
||||
'w-full p-1 bg-surface-muted text-sm text-center absolute bottom-0 left-0',
|
||||
{
|
||||
'text-primary-500 bg-primary-100':
|
||||
selectedTemplate === template.name,
|
||||
'text-body': selectedTemplate !== template.name,
|
||||
},
|
||||
]"
|
||||
>
|
||||
{{ template.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="modalData && !modalData.store.isEdit"
|
||||
class="z-0 flex ml-3 pt-5"
|
||||
>
|
||||
<BaseCheckbox
|
||||
v-model="modalData.isMarkAsDefault"
|
||||
:set-initial-value="false"
|
||||
variant="primary"
|
||||
:label="$t('general.mark_as_default')"
|
||||
:description="modalData.markAsDefaultDescription"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
|
||||
>
|
||||
<BaseButton class="mr-3" variant="primary-outline" @click="closeModal">
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" @click="chooseTemplate">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('general.choose') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="w-full mt-4 tax-select">
|
||||
<Popover v-slot="{ open: isOpen }" class="relative">
|
||||
<PopoverButton
|
||||
class="flex items-center text-sm font-medium text-primary-400 focus:outline-hidden focus:border-none"
|
||||
>
|
||||
<BaseIcon name="PlusIcon" class="w-4 h-4 font-medium text-primary-400" />
|
||||
{{ $t('settings.tax_types.add_tax') }}
|
||||
</PopoverButton>
|
||||
|
||||
<div class="relative w-full max-w-md px-4">
|
||||
<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"
|
||||
>
|
||||
<PopoverPanel
|
||||
v-slot="{ close }"
|
||||
style="min-width: 350px; margin-left: 62px; top: -28px"
|
||||
class="absolute z-10 px-4 py-2 -translate-x-full sm:px-0"
|
||||
>
|
||||
<div class="overflow-hidden rounded-xl shadow ring-1 ring-black/5">
|
||||
<!-- Search Input -->
|
||||
<div class="relative bg-surface">
|
||||
<div class="relative p-4">
|
||||
<BaseInput
|
||||
v-model="textSearch"
|
||||
:placeholder="$t('general.search')"
|
||||
type="text"
|
||||
class="text-heading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- List of Taxes -->
|
||||
<div
|
||||
v-if="filteredTaxType.length > 0"
|
||||
class="relative flex flex-col overflow-auto list max-h-36 border-t border-line-light"
|
||||
>
|
||||
<div
|
||||
v-for="(taxType, idx) in filteredTaxType"
|
||||
:key="idx"
|
||||
:class="{
|
||||
'bg-surface-tertiary cursor-not-allowed opacity-50 pointer-events-none':
|
||||
existingTaxIds.has(taxType.id),
|
||||
}"
|
||||
tabindex="2"
|
||||
class="px-6 py-4 border-b border-line-light border-solid cursor-pointer hover:bg-surface-tertiary hover:cursor-pointer last:border-b-0"
|
||||
@click="selectTaxType(taxType, close)"
|
||||
>
|
||||
<div class="flex justify-between px-2">
|
||||
<label
|
||||
class="m-0 text-base font-semibold leading-tight text-body cursor-pointer"
|
||||
>
|
||||
{{ taxType.name }}
|
||||
</label>
|
||||
<label
|
||||
class="m-0 text-base font-semibold text-body cursor-pointer"
|
||||
>
|
||||
<template v-if="taxType.calculation_type === 'fixed'">
|
||||
<BaseFormatMoney :amount="taxType.fixed_amount" :currency="companyCurrency" />
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ taxType.percent }} %
|
||||
</template>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex justify-center p-5 text-subtle">
|
||||
<label class="text-base text-muted cursor-pointer">
|
||||
{{ $t('general.no_tax_found') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add new Tax action -->
|
||||
<button
|
||||
v-if="canCreateTaxType"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-full h-10 px-2 py-3 bg-surface-muted border-none outline-hidden"
|
||||
@click="openTaxTypeModal"
|
||||
>
|
||||
<BaseIcon name="CheckCircleIcon" class="text-primary-400" />
|
||||
<label
|
||||
class="m-0 ml-3 text-sm leading-none cursor-pointer font-base text-primary-400"
|
||||
>
|
||||
{{ $t('estimates.add_new_tax') }}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</transition>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '../../../stores/modal.store'
|
||||
import type { TaxType } from '../../../types/domain/tax'
|
||||
import type { Currency } from '../../../types/domain/currency'
|
||||
import type { DocumentFormData, DocumentTax } from './use-document-calculations'
|
||||
|
||||
interface Props {
|
||||
type?: string | null
|
||||
store: Record<string, unknown>
|
||||
storeProp: string
|
||||
taxTypes?: TaxType[]
|
||||
companyCurrency?: Currency | Record<string, unknown> | null
|
||||
canCreateTaxType?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select:taxType', taxType: TaxType): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: null,
|
||||
taxTypes: () => [],
|
||||
companyCurrency: null,
|
||||
canCreateTaxType: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modalStore = useModalStore()
|
||||
const textSearch = ref<string | null>(null)
|
||||
|
||||
const formData = computed<DocumentFormData>(() => {
|
||||
return props.store[props.storeProp] as DocumentFormData
|
||||
})
|
||||
|
||||
const filteredTaxType = computed<TaxType[]>(() => {
|
||||
if (textSearch.value) {
|
||||
return props.taxTypes.filter((el) =>
|
||||
el.name.toLowerCase().includes(textSearch.value!.toLowerCase()),
|
||||
)
|
||||
}
|
||||
return props.taxTypes
|
||||
})
|
||||
|
||||
const taxes = computed<DocumentTax[]>(() => {
|
||||
return formData.value.taxes
|
||||
})
|
||||
|
||||
const existingTaxIds = computed<Set<number>>(() => {
|
||||
return new Set(taxes.value.map((t) => t.tax_type_id))
|
||||
})
|
||||
|
||||
function selectTaxType(data: TaxType, close: () => void): void {
|
||||
emit('select:taxType', { ...data })
|
||||
close()
|
||||
}
|
||||
|
||||
function openTaxTypeModal(): void {
|
||||
modalStore.openModal({
|
||||
title: t('settings.tax_types.add_tax'),
|
||||
componentName: 'TaxTypeModal',
|
||||
size: 'sm',
|
||||
refreshData: (data: TaxType) => emit('select:taxType', data),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="flex text-heading font-medium text-sm mb-2">
|
||||
{{ $t('general.select_template') }}
|
||||
<span class="text-sm text-red-500"> *</span>
|
||||
</label>
|
||||
<BaseButton
|
||||
type="button"
|
||||
class="flex justify-center w-full text-sm lg:w-auto hover:bg-surface-muted"
|
||||
variant="gray"
|
||||
@click="openTemplateModal"
|
||||
>
|
||||
<template #right="slotProps">
|
||||
<BaseIcon name="PencilIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ templateName }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import type { DocumentFormData } from './use-document-calculations'
|
||||
|
||||
interface Props {
|
||||
store: Record<string, unknown> & {
|
||||
templates: Array<{ name: string; path?: string }>
|
||||
}
|
||||
storeProp: string
|
||||
isMarkAsDefault?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isMarkAsDefault: false,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const modalStore = useModalStore()
|
||||
|
||||
const formData = computed<DocumentFormData>(() => {
|
||||
return props.store[props.storeProp] as DocumentFormData
|
||||
})
|
||||
|
||||
const templateName = computed<string>(() => {
|
||||
return formData.value.template_name ?? ''
|
||||
})
|
||||
|
||||
function openTemplateModal(): void {
|
||||
let markAsDefaultDescription = ''
|
||||
if (props.storeProp === 'newEstimate') {
|
||||
markAsDefaultDescription = t('estimates.mark_as_default_estimate_template_description')
|
||||
} else if (props.storeProp === 'newInvoice') {
|
||||
markAsDefaultDescription = t('invoices.mark_as_default_invoice_template_description')
|
||||
}
|
||||
|
||||
modalStore.openModal({
|
||||
title: t('general.choose_template'),
|
||||
componentName: 'SelectTemplate',
|
||||
data: {
|
||||
templates: props.store.templates,
|
||||
store: props.store,
|
||||
storeProp: props.storeProp,
|
||||
isMarkAsDefault: props.isMarkAsDefault,
|
||||
markAsDefaultDescription,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
26
resources/scripts/features/shared/document-form/index.ts
Normal file
26
resources/scripts/features/shared/document-form/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export { default as DocumentItemsTable } from './DocumentItemsTable.vue'
|
||||
export { default as DocumentItemRow } from './DocumentItemRow.vue'
|
||||
export { default as DocumentItemRowTax } from './DocumentItemRowTax.vue'
|
||||
export { default as DocumentTotals } from './DocumentTotals.vue'
|
||||
export { default as DocumentNotes } from './DocumentNotes.vue'
|
||||
export { default as TaxSelectPopup } from './TaxSelectPopup.vue'
|
||||
export { default as NoteSelectPopup } from './NoteSelectPopup.vue'
|
||||
export { default as TemplateSelectButton } from './TemplateSelectButton.vue'
|
||||
export { default as SelectTemplateModal } from './SelectTemplateModal.vue'
|
||||
export { default as ExchangeRateConverter } from './ExchangeRateConverter.vue'
|
||||
|
||||
export {
|
||||
useDocumentCalculations,
|
||||
calcItemSubtotal,
|
||||
calcItemDiscountVal,
|
||||
calcItemTotal,
|
||||
calcTaxAmount,
|
||||
} from './use-document-calculations'
|
||||
|
||||
export type {
|
||||
DocumentItem,
|
||||
DocumentFormData,
|
||||
DocumentTax,
|
||||
DocumentStore,
|
||||
UseDocumentCalculationsOptions,
|
||||
} from './use-document-calculations'
|
||||
@@ -0,0 +1,180 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import type { Tax } from '../../../types/domain/tax'
|
||||
|
||||
export interface DocumentItem {
|
||||
id: number | string
|
||||
name: string
|
||||
description: string | null
|
||||
quantity: number
|
||||
price: number
|
||||
discount: number
|
||||
discount_val: number
|
||||
discount_type: 'fixed' | 'percentage'
|
||||
tax: number
|
||||
total: number
|
||||
sub_total?: number
|
||||
totalTax?: number
|
||||
totalSimpleTax?: number
|
||||
totalCompoundTax?: number
|
||||
taxes?: Partial<Tax>[]
|
||||
item_id?: number | null
|
||||
unit_name?: string | null
|
||||
invoice_id?: number | null
|
||||
estimate_id?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DocumentFormData {
|
||||
id: number | null
|
||||
customer: Record<string, unknown> | null
|
||||
customer_id: number | null
|
||||
items: DocumentItem[]
|
||||
taxes: DocumentTax[]
|
||||
discount: number
|
||||
discount_val: number
|
||||
discount_type: 'fixed' | 'percentage'
|
||||
tax_per_item: string | null
|
||||
tax_included: boolean | null
|
||||
discount_per_item: string | null
|
||||
notes: string | null
|
||||
template_name: string | null
|
||||
exchange_rate?: number | null
|
||||
currency_id?: number
|
||||
selectedCurrency?: Record<string, unknown> | string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DocumentTax {
|
||||
id: number | string
|
||||
tax_type_id: number
|
||||
name: string
|
||||
amount: number
|
||||
percent: number | null
|
||||
calculation_type: string | null
|
||||
fixed_amount: number
|
||||
compound_tax: boolean
|
||||
type?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DocumentStore {
|
||||
getSubTotal: number
|
||||
getNetTotal: number
|
||||
getTotalSimpleTax: number
|
||||
getTotalCompoundTax: number
|
||||
getTotalTax: number
|
||||
getSubtotalWithDiscount: number
|
||||
getTotal: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UseDocumentCalculationsOptions {
|
||||
items: Ref<DocumentItem[]>
|
||||
taxes: Ref<DocumentTax[]>
|
||||
discountVal: Ref<number>
|
||||
taxPerItem: Ref<string | null>
|
||||
taxIncluded: Ref<boolean | null>
|
||||
}
|
||||
|
||||
export function useDocumentCalculations(options: UseDocumentCalculationsOptions) {
|
||||
const { items, taxes, discountVal, taxPerItem, taxIncluded } = options
|
||||
|
||||
const subTotal = computed<number>(() => {
|
||||
return items.value.reduce((sum: number, item: DocumentItem) => {
|
||||
return sum + (item.total ?? 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalSimpleTax = computed<number>(() => {
|
||||
return taxes.value.reduce((sum: number, tax: DocumentTax) => {
|
||||
if (!tax.compound_tax) {
|
||||
return sum + (tax.amount ?? 0)
|
||||
}
|
||||
return sum
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalCompoundTax = computed<number>(() => {
|
||||
return taxes.value.reduce((sum: number, tax: DocumentTax) => {
|
||||
if (tax.compound_tax) {
|
||||
return sum + (tax.amount ?? 0)
|
||||
}
|
||||
return sum
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalTax = computed<number>(() => {
|
||||
if (taxPerItem.value === 'NO' || taxPerItem.value === null) {
|
||||
return totalSimpleTax.value + totalCompoundTax.value
|
||||
}
|
||||
return items.value.reduce((sum: number, item: DocumentItem) => {
|
||||
return sum + (item.tax ?? 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const subtotalWithDiscount = computed<number>(() => {
|
||||
return subTotal.value - discountVal.value
|
||||
})
|
||||
|
||||
const netTotal = computed<number>(() => {
|
||||
return subtotalWithDiscount.value - totalTax.value
|
||||
})
|
||||
|
||||
const total = computed<number>(() => {
|
||||
if (taxIncluded.value) {
|
||||
return subtotalWithDiscount.value
|
||||
}
|
||||
return subtotalWithDiscount.value + totalTax.value
|
||||
})
|
||||
|
||||
return {
|
||||
subTotal,
|
||||
totalSimpleTax,
|
||||
totalCompoundTax,
|
||||
totalTax,
|
||||
subtotalWithDiscount,
|
||||
netTotal,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** Calculate item-level subtotal (price * quantity) */
|
||||
export function calcItemSubtotal(price: number, quantity: number): number {
|
||||
return Math.round(price * quantity)
|
||||
}
|
||||
|
||||
/** Calculate item-level discount value */
|
||||
export function calcItemDiscountVal(
|
||||
subtotal: number,
|
||||
discount: number,
|
||||
discountType: 'fixed' | 'percentage',
|
||||
): number {
|
||||
const absSubtotal = Math.abs(subtotal)
|
||||
if (discountType === 'percentage') {
|
||||
return Math.round((absSubtotal * discount) / 100)
|
||||
}
|
||||
return Math.min(Math.round(discount * 100), absSubtotal)
|
||||
}
|
||||
|
||||
/** Calculate item-level total after discount */
|
||||
export function calcItemTotal(subtotal: number, discountVal: number): number {
|
||||
return subtotal - discountVal
|
||||
}
|
||||
|
||||
/** Calculate tax amount for a given total and tax config */
|
||||
export function calcTaxAmount(
|
||||
total: number,
|
||||
percent: number | null,
|
||||
fixedAmount: number | null,
|
||||
calculationType: string | null,
|
||||
taxIncluded: boolean | null,
|
||||
): number {
|
||||
if (calculationType === 'fixed' && fixedAmount != null) {
|
||||
return fixedAmount
|
||||
}
|
||||
if (!total || !percent) return 0
|
||||
if (taxIncluded) {
|
||||
return Math.round(total - total / (1 + percent / 100))
|
||||
}
|
||||
return Math.round((total * percent) / 100)
|
||||
}
|
||||
Reference in New Issue
Block a user