mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-02 21:31:01 +00:00
323 lines
14 KiB
Vue
323 lines
14 KiB
Vue
<template>
|
|
<BasePage class="relative payment-create">
|
|
<form @submit.prevent="submitPaymentData">
|
|
<BasePageHeader :title="pageTitle" class="mb-5">
|
|
<BaseBreadcrumb>
|
|
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
|
|
<BaseBreadcrumbItem :title="$t('payments.payment', 2)" to="/admin/payments" />
|
|
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
|
|
</BaseBreadcrumb>
|
|
|
|
<template #actions>
|
|
<BaseButton :loading="isSaving" :disabled="isSaving" variant="primary" type="submit" class="hidden sm:flex">
|
|
<template #left="slotProps">
|
|
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
|
</template>
|
|
{{ isEdit ? $t('payments.update_payment') : $t('payments.save_payment') }}
|
|
</BaseButton>
|
|
</template>
|
|
</BasePageHeader>
|
|
|
|
<BaseCard>
|
|
<BaseInputGrid>
|
|
<BaseInputGroup :label="$t('payments.date')" :content-loading="isLoadingContent" required>
|
|
<BaseDatePicker v-model="paymentStore.currentPayment.payment_date" :content-loading="isLoadingContent" :calendar-button="true" calendar-button-icon="calendar" />
|
|
</BaseInputGroup>
|
|
|
|
<BaseInputGroup :label="$t('payments.payment_number')" :content-loading="isLoadingContent" required>
|
|
<BaseInput v-model="paymentStore.currentPayment.payment_number" :content-loading="isLoadingContent" />
|
|
</BaseInputGroup>
|
|
|
|
<BaseInputGroup :label="$t('payments.customer')" :content-loading="isLoadingContent" required>
|
|
<BaseCustomerSelectInput
|
|
v-if="!isLoadingContent"
|
|
v-model="paymentStore.currentPayment.customer_id"
|
|
:content-loading="isLoadingContent"
|
|
:placeholder="$t('customers.select_a_customer')"
|
|
show-action
|
|
@update:model-value="onManualCustomerSelect"
|
|
/>
|
|
</BaseInputGroup>
|
|
|
|
<BaseInputGroup :label="$t('payments.amount')" :content-loading="isLoadingContent" required>
|
|
<BaseMoney
|
|
:key="String(paymentStore.currentPayment.currency)"
|
|
v-model="amount"
|
|
:currency="paymentStore.currentPayment.currency"
|
|
:content-loading="isLoadingContent"
|
|
/>
|
|
</BaseInputGroup>
|
|
|
|
<ExchangeRateConverter
|
|
:store="exchangeRateStore"
|
|
store-prop="currentPayment"
|
|
:v="{ exchange_rate: { $error: false, $errors: [], $touch: () => {} } }"
|
|
:is-loading="isLoadingContent"
|
|
:is-edit="isEdit"
|
|
:customer-currency="paymentStore.currentPayment.currency_id"
|
|
/>
|
|
|
|
<BaseInputGroup :content-loading="isLoadingContent" :label="$t('payments.payment_mode')">
|
|
<BaseMultiselect
|
|
v-model="paymentStore.currentPayment.payment_method_id"
|
|
:content-loading="isLoadingContent"
|
|
label="name"
|
|
value-prop="id"
|
|
track-by="name"
|
|
:options="paymentStore.paymentModes"
|
|
:placeholder="$t('payments.select_payment_mode')"
|
|
searchable
|
|
/>
|
|
</BaseInputGroup>
|
|
</BaseInputGrid>
|
|
|
|
<section class="pt-6 mt-6 border-t border-line-default">
|
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
<div>
|
|
<h2 class="text-base font-semibold text-heading">{{ $t('payments.allocations') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ $t('payments.allocations_description') }}</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<BaseButton type="button" size="sm" variant="primary-outline" :disabled="!invoiceList.length || !amount" @click="allocateOldestFirst">
|
|
{{ $t('payments.allocate_oldest_first') }}
|
|
</BaseButton>
|
|
<BaseButton type="button" size="sm" variant="primary-outline" :disabled="!paymentStore.currentPayment.customer_id" @click="addAllocation">
|
|
<template #left="slotProps"><BaseIcon name="PlusIcon" :class="slotProps.class" /></template>
|
|
{{ $t('payments.add_allocation') }}
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!paymentStore.currentPayment.customer_id" class="p-4 text-sm rounded-lg bg-surface-secondary text-muted">
|
|
{{ $t('payments.select_customer_to_allocate') }}
|
|
</div>
|
|
|
|
<div v-else-if="!paymentStore.currentPayment.allocations.length" class="p-4 text-sm rounded-lg bg-surface-secondary text-muted">
|
|
{{ $t('payments.no_allocations') }}
|
|
</div>
|
|
|
|
<div v-else class="space-y-3">
|
|
<div v-for="(allocation, index) in paymentStore.currentPayment.allocations" :key="`${allocation.invoice_id}-${index}`" class="grid items-end gap-3 p-3 rounded-lg bg-surface-secondary md:grid-cols-[minmax(0,1fr)_12rem_auto]">
|
|
<BaseInputGroup :label="$t('payments.invoice')">
|
|
<BaseMultiselect
|
|
v-model="allocation.invoice_id"
|
|
value-prop="id"
|
|
track-by="invoice_number"
|
|
label="invoice_number"
|
|
:options="availableInvoices(allocation.invoice_id)"
|
|
:loading="isLoadingInvoices"
|
|
:placeholder="$t('invoices.select_invoice')"
|
|
/>
|
|
</BaseInputGroup>
|
|
<BaseInputGroup :label="$t('payments.amount')">
|
|
<BaseMoney :model-value="allocation.amount / 100" :currency="paymentStore.currentPayment.currency" @update:model-value="setAllocationAmount(index, $event)" />
|
|
</BaseInputGroup>
|
|
<div class="justify-self-end">
|
|
<BaseButton type="button" size="sm" variant="gray" :aria-label="$t('payments.remove_allocation')" @click="removeAllocation(index)">
|
|
<BaseIcon name="TrashIcon" />
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap justify-end gap-x-8 gap-y-2 pt-4 mt-4 text-sm border-t border-line-default">
|
|
<span class="text-muted">{{ $t('payments.allocated') }}: <BaseFormatMoney :amount="allocatedAmount" :currency="paymentStore.currentPayment.currency" /></span>
|
|
<span :class="unallocatedAmount < 0 ? 'text-status-red' : 'text-heading'">{{ $t('payments.unapplied_credit') }}: <BaseFormatMoney :amount="Math.max(unallocatedAmount, 0)" :currency="paymentStore.currentPayment.currency" /></span>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="relative mt-6">
|
|
<label class="mb-4 text-sm font-medium text-heading">{{ $t('estimates.notes') }}</label>
|
|
<BaseCustomInput v-model="paymentStore.currentPayment.notes" :content-loading="isLoadingContent" :fields="paymentFields" class="mt-1" />
|
|
</div>
|
|
|
|
<BaseButton :loading="isSaving" :content-loading="isLoadingContent" variant="primary" type="submit" class="flex justify-center w-full mt-4 sm:hidden">
|
|
{{ isEdit ? $t('payments.update_payment') : $t('payments.save_payment') }}
|
|
</BaseButton>
|
|
</BaseCard>
|
|
</form>
|
|
</BasePage>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { usePaymentStore } from '../store'
|
|
import { useCompanyStore } from '../../../../stores/company.store'
|
|
import { useNotificationStore } from '../../../../stores/notification.store'
|
|
import { handleApiError, getErrorTranslationKey } from '../../../../utils/error-handling'
|
|
import { invoiceService } from '../../../../api/services/invoice.service'
|
|
import { customerService } from '../../../../api/services/customer.service'
|
|
import { ExchangeRateConverter } from '../../../shared/document-form'
|
|
import type { Invoice } from '../../../../types/domain/invoice'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { t } = useI18n()
|
|
const paymentStore = usePaymentStore()
|
|
const exchangeRateStore = paymentStore as unknown as Record<string, unknown> & { showExchangeRate: boolean }
|
|
const companyStore = useCompanyStore()
|
|
const notificationStore = useNotificationStore()
|
|
|
|
const isSaving = ref(false)
|
|
const isLoadingInvoices = ref(false)
|
|
const invoiceList = ref<Invoice[]>([])
|
|
const paymentFields = ref(['customer', 'company', 'customerCustom', 'payment', 'paymentCustom'])
|
|
|
|
const amount = computed<number>({
|
|
get: () => paymentStore.currentPayment.amount / 100,
|
|
set: (value) => { paymentStore.currentPayment.amount = Math.round(value * 100) },
|
|
})
|
|
|
|
const allocatedAmount = computed(() => paymentStore.currentPayment.allocations.reduce((total, allocation) => total + allocation.amount, 0))
|
|
const unallocatedAmount = computed(() => paymentStore.currentPayment.amount - allocatedAmount.value)
|
|
const isLoadingContent = computed(() => paymentStore.isFetchingInitialData)
|
|
const isEdit = computed(() => route.name === 'payments.edit')
|
|
const pageTitle = computed(() => isEdit.value ? t('payments.edit_payment') : t('payments.new_payment'))
|
|
|
|
paymentStore.resetCurrentPayment()
|
|
|
|
if (route.query.customer) paymentStore.currentPayment.customer_id = Number(route.query.customer)
|
|
|
|
paymentStore.fetchPaymentInitialData(
|
|
isEdit.value,
|
|
{ id: isEdit.value ? String(route.params.id) : undefined },
|
|
companyStore.selectedCompanyCurrency ?? undefined,
|
|
)
|
|
|
|
if (route.params.id && !isEdit.value) void setInvoiceFromUrl()
|
|
|
|
watch(
|
|
() => paymentStore.currentPayment.customer_id,
|
|
(customerId, previousCustomerId) => {
|
|
if (!customerId) return
|
|
if (previousCustomerId && customerId !== previousCustomerId) paymentStore.currentPayment.allocations = []
|
|
void onCustomerChange(customerId)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
async function setInvoiceFromUrl(): Promise<void> {
|
|
try {
|
|
const invoice = (await invoiceService.get(Number(route.params.id))).data
|
|
paymentStore.currentPayment.customer_id = invoice.customer_id
|
|
paymentStore.currentPayment.allocations = [{ invoice_id: invoice.id, amount: invoice.due_amount }]
|
|
if (paymentStore.currentPayment.amount === 0) paymentStore.currentPayment.amount = invoice.due_amount
|
|
} catch {
|
|
// The normal form remains usable if the originating invoice no longer exists.
|
|
}
|
|
}
|
|
|
|
async function onCustomerChange(customerId: number): Promise<void> {
|
|
isLoadingInvoices.value = true
|
|
try {
|
|
const [invoiceResponse, customerResponse] = await Promise.all([
|
|
invoiceService.list({ customer_id: customerId, limit: 'all' } as never),
|
|
customerService.get(customerId),
|
|
])
|
|
invoiceList.value = [...(invoiceResponse.data as unknown as Invoice[])]
|
|
const customer = customerResponse.data
|
|
paymentStore.currentPayment.customer = customer
|
|
paymentStore.currentPayment.selectedCustomer = customer
|
|
if (customer.currency) {
|
|
paymentStore.currentPayment.currency = customer.currency
|
|
paymentStore.currentPayment.currency_id = customer.currency.id
|
|
}
|
|
} catch {
|
|
invoiceList.value = []
|
|
} finally {
|
|
isLoadingInvoices.value = false
|
|
}
|
|
}
|
|
|
|
function availableInvoices(selectedInvoiceId: number): Invoice[] {
|
|
const chosenInvoiceIds = paymentStore.currentPayment.allocations
|
|
.map(({ invoice_id }) => invoice_id)
|
|
.filter((id) => id !== selectedInvoiceId)
|
|
return invoiceList.value
|
|
.filter((invoice) => isEligibleInvoice(invoice) || invoice.id === selectedInvoiceId)
|
|
.filter((invoice) => !chosenInvoiceIds.includes(invoice.id))
|
|
}
|
|
|
|
function isEligibleInvoice(invoice: Invoice): boolean {
|
|
return invoice.type === 'INVOICE' && invoice.status !== 'DRAFT' && invoice.due_amount > 0
|
|
}
|
|
|
|
function onManualCustomerSelect(): void {
|
|
paymentStore.currentPayment.allocations = []
|
|
paymentStore.currentPayment.amount = 0
|
|
const params: Record<string, unknown> = { userId: paymentStore.currentPayment.customer_id }
|
|
if (route.params.id) params.model_id = route.params.id
|
|
void paymentStore.getNextNumber(params, true)
|
|
}
|
|
|
|
function addAllocation(): void {
|
|
const firstInvoice = availableInvoices(0)[0]
|
|
if (firstInvoice) paymentStore.currentPayment.allocations.push({ invoice_id: firstInvoice.id, amount: 0 })
|
|
}
|
|
|
|
function removeAllocation(index: number): void {
|
|
paymentStore.currentPayment.allocations.splice(index, 1)
|
|
}
|
|
|
|
function setAllocationAmount(index: number, value: number): void {
|
|
const allocation = paymentStore.currentPayment.allocations[index]
|
|
if (allocation) allocation.amount = Math.round(value * 100)
|
|
}
|
|
|
|
function allocateOldestFirst(): void {
|
|
let remaining = paymentStore.currentPayment.amount
|
|
const allocations: Array<{ invoice_id: number; amount: number }> = []
|
|
for (const invoice of [...invoiceList.value]
|
|
.filter(isEligibleInvoice)
|
|
.sort((a, b) => (a.due_date ?? '9999-12-31').localeCompare(b.due_date ?? '9999-12-31') || a.id - b.id)) {
|
|
if (remaining <= 0) break
|
|
const allocated = Math.min(remaining, invoice.due_amount)
|
|
allocations.push({ invoice_id: invoice.id, amount: allocated })
|
|
remaining -= allocated
|
|
}
|
|
paymentStore.currentPayment.allocations = allocations
|
|
}
|
|
|
|
async function submitPaymentData(): Promise<void> {
|
|
if (unallocatedAmount.value < 0) {
|
|
notificationStore.showNotification({ type: 'error', message: t('payments.allocation_exceeds_amount') })
|
|
return
|
|
}
|
|
|
|
isSaving.value = true
|
|
const current = paymentStore.currentPayment
|
|
const data = {
|
|
...(isEdit.value ? { id: current.id } : {}),
|
|
payment_date: current.payment_date,
|
|
payment_number: current.payment_number,
|
|
customer_id: current.customer_id,
|
|
amount: current.amount,
|
|
payment_method_id: current.payment_method_id,
|
|
notes: current.notes,
|
|
currency_id: current.currency_id,
|
|
exchange_rate: current.exchange_rate,
|
|
customFields: current.customFields,
|
|
fields: current.fields,
|
|
allocations: current.allocations.filter(({ invoice_id, amount }) => invoice_id && amount > 0),
|
|
}
|
|
|
|
try {
|
|
const response = await (isEdit.value ? paymentStore.updatePayment(data) : paymentStore.addPayment(data))
|
|
await router.push(`/admin/payments/${response.data.data.id}/view`)
|
|
} catch (error) {
|
|
const normalized = handleApiError(error)
|
|
const translationKey = getErrorTranslationKey(normalized.message)
|
|
notificationStore.showNotification({ type: 'error', message: translationKey ? t(translationKey) : normalized.message })
|
|
} finally {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
paymentStore.resetCurrentPayment()
|
|
invoiceList.value = []
|
|
})
|
|
</script>
|