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:
Darko Gjorgjijoski
2026-04-07 12:50:16 +02:00
parent 064bdf5395
commit 71388ec6a5
448 changed files with 381 additions and 382 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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'

View File

@@ -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)
}