mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-17 18:24:10 +00:00
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>
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
<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>
|
||||
<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 DocumentItemRowTax from './DocumentItemRowTax.vue'
|
||||
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
|
||||
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 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 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: crypto.randomUUID(),
|
||||
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,263 @@
|
||||
<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>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="rounded-xl border border-line-light shadow overflow-hidden 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,388 @@
|
||||
<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>
|
||||
@@ -0,0 +1,189 @@
|
||||
<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, onBeforeUnmount } from 'vue'
|
||||
import { exchangeRateService } from '../../../api/services/exchange-rate.service'
|
||||
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
|
||||
companyCurrency?: Currency | null
|
||||
currencies?: Currency[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
isEdit: false,
|
||||
customerCurrency: null,
|
||||
companyCurrency: null,
|
||||
currencies: () => [],
|
||||
})
|
||||
|
||||
const hasActiveProvider = ref<boolean>(false)
|
||||
const isFetching = ref<boolean>(false)
|
||||
|
||||
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 selectedCurrency = computed<Currency | null>(() => {
|
||||
return (
|
||||
props.currencies.find((c) => c.id === formData.value.currency_id) ?? null
|
||||
)
|
||||
})
|
||||
|
||||
const isCurrencyDifferent = computed<boolean>(() => {
|
||||
return props.companyCurrency?.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) => {
|
||||
if (res.has_active_provider) {
|
||||
hasActiveProvider.value = true
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 (props.companyCurrency) {
|
||||
formData.value.currency_id = props.companyCurrency.id
|
||||
}
|
||||
}
|
||||
|
||||
async function onChangeCurrency(v: number | undefined): Promise<void> {
|
||||
if (v !== props.companyCurrency?.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))
|
||||
if (res && res.exchange_rate != null) {
|
||||
formData.value.exchange_rate = res.exchange_rate
|
||||
} else {
|
||||
formData.value.exchange_rate = null
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
isFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.store.showExchangeRate = false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Note } from '../../../types/domain/note'
|
||||
import { noteService } from '../../../api/services/note.service'
|
||||
|
||||
interface Props {
|
||||
type?: string | null
|
||||
canViewNotes?: boolean
|
||||
canManageNotes?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', data: Note): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: null,
|
||||
canViewNotes: true,
|
||||
canManageNotes: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const textSearch = ref<string | null>(null)
|
||||
const notes = ref<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 {
|
||||
const modalStore = (window as Record<string, unknown>).__modalStore as
|
||||
| { openModal: (opts: Record<string, unknown>) => void }
|
||||
| undefined
|
||||
modalStore?.openModal({
|
||||
title: t('settings.customization.notes.add_note'),
|
||||
componentName: 'NoteModal',
|
||||
size: 'lg',
|
||||
data: props.type,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,172 @@
|
||||
<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 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 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 {
|
||||
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',
|
||||
size: 'sm',
|
||||
refreshData: (data: TaxType) => emit('select:taxType', data),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<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 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 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')
|
||||
}
|
||||
|
||||
const modalStore = (window as Record<string, unknown>).__modalStore as
|
||||
| { openModal: (opts: Record<string, unknown>) => void }
|
||||
| undefined
|
||||
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>
|
||||
25
resources/scripts-v2/features/shared/document-form/index.ts
Normal file
25
resources/scripts-v2/features/shared/document-form/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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 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