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:
Darko Gjorgjijoski
2026-04-04 06:30:00 +02:00
parent e43e515614
commit 774b2614f0
77 changed files with 14451 additions and 0 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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