mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-03 13:51:08 +00:00
335 lines
19 KiB
Vue
335 lines
19 KiB
Vue
<template>
|
|
<div class="mt-6 space-y-6">
|
|
<BaseCard>
|
|
<div class="flex flex-wrap items-end gap-4">
|
|
<BaseInputGroup :label="$t('customers.statement_type')" class="min-w-48">
|
|
<BaseMultiselect v-model="filters.type" :options="statementTypes" :allow-empty="false" :can-deselect="false" :show-labels="false" />
|
|
</BaseInputGroup>
|
|
|
|
<template v-if="filters.type === 'activity'">
|
|
<BaseInputGroup :label="$t('customers.from_date')" class="min-w-44">
|
|
<BaseDatePicker v-model="filters.from_date" />
|
|
</BaseInputGroup>
|
|
<BaseInputGroup :label="$t('customers.to_date')" class="min-w-44">
|
|
<BaseDatePicker v-model="filters.to_date" />
|
|
</BaseInputGroup>
|
|
</template>
|
|
<BaseInputGroup v-else :label="$t('customers.as_of')" class="min-w-44">
|
|
<BaseDatePicker v-model="filters.as_of" />
|
|
</BaseInputGroup>
|
|
|
|
<div class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto">
|
|
<BaseButton variant="primary-outline" :loading="isLoading" @click="refreshStatement">
|
|
<template #left="slotProps"><BaseIcon name="ArrowPathIcon" :class="slotProps.class" /></template>
|
|
{{ $t('general.refresh') }}
|
|
</BaseButton>
|
|
<a v-if="pdfUrl" :href="pdfUrl" target="_blank" rel="noopener">
|
|
<BaseButton variant="primary-outline">
|
|
<template #left="slotProps"><BaseIcon name="ArrowDownTrayIcon" :class="slotProps.class" /></template>
|
|
{{ $t('customers.download_statement') }}
|
|
</BaseButton>
|
|
</a>
|
|
<BaseButton variant="primary" :disabled="!statement" @click="openEmailModal">
|
|
<template #left="slotProps"><BaseIcon name="PaperAirplaneIcon" :class="slotProps.class" /></template>
|
|
{{ $t('customers.send_statement') }}
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</BaseCard>
|
|
|
|
<BaseCard v-if="isLoading" class="py-12 text-center text-muted">
|
|
{{ $t('general.loading') }}
|
|
</BaseCard>
|
|
|
|
<template v-else-if="statement">
|
|
<div class="grid gap-4 md:grid-cols-3 xl:grid-cols-2 min-[1400px]:grid-cols-3">
|
|
<BalanceCard :label="$t('customers.invoice_due')" :amount="invoiceDueAmount" :currency="statement.currency ?? customer.currency" />
|
|
<BalanceCard :label="$t('customers.available_credit')" :amount="availableCredit" :currency="statement.currency ?? customer.currency" />
|
|
<BalanceCard class="xl:col-span-2 min-[1400px]:col-span-1" :label="$t('customers.net_account_balance')" :amount="accountBalance" :currency="statement.currency ?? customer.currency" :credit-label="$t('customers.credit')" />
|
|
</div>
|
|
|
|
<BaseCard>
|
|
<div class="flex items-center justify-between gap-4 mb-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-heading">{{ filters.type === 'activity' ? $t('customers.account_activity') : $t('customers.outstanding_items') }}</h2>
|
|
<p v-if="filters.type === 'activity'" class="mt-1 text-sm text-muted">
|
|
{{ $t('customers.opening_balance') }}: <BaseFormatMoney :amount="statement.opening_balance ?? 0" :currency="statement.currency ?? customer.currency" />
|
|
</p>
|
|
</div>
|
|
<BaseButton v-if="availableCredit > 0 && canEditPayments" variant="primary-outline" @click="openCreditModal">
|
|
{{ $t('customers.apply_credit') }}
|
|
</BaseButton>
|
|
</div>
|
|
|
|
<div v-if="filters.type === 'activity'" class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead class="border-b border-line-default text-muted">
|
|
<tr>
|
|
<th class="px-3 py-3 text-left font-medium">{{ $t('general.date') }}</th>
|
|
<th class="px-3 py-3 text-left font-medium">{{ $t('customers.activity') }}</th>
|
|
<th class="px-3 py-3 text-right font-medium">{{ $t('customers.debit') }}</th>
|
|
<th class="px-3 py-3 text-right font-medium">{{ $t('customers.credit') }}</th>
|
|
<th class="px-3 py-3 text-right font-medium">{{ $t('customers.balance') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="entry in statement.entries ?? []" :key="`${entry.entry_type}-${entry.id}`" class="border-b border-line-light">
|
|
<td class="px-3 py-3 whitespace-nowrap text-body">{{ entry.date }}</td>
|
|
<td class="px-3 py-3 text-heading"><span class="font-medium">{{ entry.reference }}</span><span v-if="entry.description" class="block text-xs text-muted">{{ entry.description }}</span></td>
|
|
<td class="px-3 py-3 text-right whitespace-nowrap"><BaseFormatMoney :amount="entry.debit_amount" :currency="statement.currency ?? customer.currency" /></td>
|
|
<td class="px-3 py-3 text-right whitespace-nowrap"><BaseFormatMoney :amount="entry.credit_amount" :currency="statement.currency ?? customer.currency" /></td>
|
|
<td class="px-3 py-3 text-right font-medium whitespace-nowrap text-heading"><BaseFormatMoney :amount="entry.balance ?? 0" :currency="statement.currency ?? customer.currency" /></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-if="!(statement.entries?.length)" class="py-8 text-center text-sm text-muted">{{ $t('customers.no_statement_activity') }}</p>
|
|
<BaseTablePagination
|
|
v-if="statement.meta"
|
|
:pagination="{
|
|
currentPage: statement.meta.current_page,
|
|
totalPages: statement.meta.last_page,
|
|
totalCount: statement.meta.total,
|
|
count: statement.entries?.length ?? 0,
|
|
limit: statement.meta.per_page,
|
|
}"
|
|
@page-change="changeActivityPage"
|
|
/>
|
|
</div>
|
|
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead class="border-b border-line-default text-muted">
|
|
<tr>
|
|
<th class="px-3 py-3 text-left font-medium">{{ $t('payments.invoice') }}</th>
|
|
<th class="px-3 py-3 text-left font-medium">{{ $t('invoices.due_date') }}</th>
|
|
<th class="px-3 py-3 text-right font-medium">{{ $t('customers.original_amount') }}</th>
|
|
<th class="px-3 py-3 text-right font-medium">{{ $t('customers.applied') }}</th>
|
|
<th class="px-3 py-3 text-right font-medium">{{ $t('customers.remaining') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="invoice in statement.invoices ?? []" :key="invoice.id" class="border-b border-line-light">
|
|
<td class="px-3 py-3 whitespace-nowrap"><router-link :to="`/admin/invoices/${invoice.id}/view`" class="font-medium text-primary-500">{{ invoice.invoice_number }}</router-link></td>
|
|
<td class="px-3 py-3 whitespace-nowrap text-body">{{ invoice.due_date }}</td>
|
|
<td class="px-3 py-3 text-right whitespace-nowrap"><BaseFormatMoney :amount="invoice.original_amount" :currency="statement.currency ?? customer.currency" /></td>
|
|
<td class="px-3 py-3 text-right whitespace-nowrap"><BaseFormatMoney :amount="invoice.applied_amount" :currency="statement.currency ?? customer.currency" /></td>
|
|
<td class="px-3 py-3 text-right font-medium whitespace-nowrap text-heading"><BaseFormatMoney :amount="invoice.remaining_amount" :currency="statement.currency ?? customer.currency" /></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-if="!(statement.invoices?.length)" class="py-8 text-center text-sm text-muted">{{ $t('customers.no_outstanding_invoices') }}</p>
|
|
</div>
|
|
<div v-if="filters.type === 'outstanding' && statement.credits?.length" class="pt-5 mt-5 border-t border-line-default">
|
|
<h3 class="mb-3 text-sm font-semibold text-heading">{{ $t('customers.available_credit') }}</h3>
|
|
<div v-for="credit in statement.credits" :key="credit.id" class="flex items-center justify-between py-2 text-sm border-b border-line-light">
|
|
<router-link :to="`/admin/payments/${credit.id}/view`" class="font-medium text-primary-500">{{ credit.payment_number }}</router-link>
|
|
<BaseFormatMoney :amount="credit.available_amount" :currency="statement.currency ?? customer.currency" class="font-medium text-status-green" />
|
|
</div>
|
|
</div>
|
|
</BaseCard>
|
|
</template>
|
|
|
|
<BaseModal :show="showEmailModal" @close="showEmailModal = false">
|
|
<template #header><div class="flex items-center justify-between w-full">{{ $t('customers.send_statement') }}<BaseIcon name="XMarkIcon" class="w-5 h-5 cursor-pointer text-muted" @click="showEmailModal = false" /></div></template>
|
|
<form @submit.prevent="sendStatement">
|
|
<div class="p-6 space-y-4">
|
|
<BaseInputGroup :label="$t('general.to')" required><BaseInput v-model="emailForm.to" type="email" /></BaseInputGroup>
|
|
<BaseInputGroup :label="$t('general.cc')"><BaseInput v-model="emailForm.cc" type="email" /></BaseInputGroup>
|
|
<BaseInputGroup :label="$t('general.bcc')"><BaseInput v-model="emailForm.bcc" type="email" /></BaseInputGroup>
|
|
<BaseInputGroup :label="$t('general.subject')" required><BaseInput v-model="emailForm.subject" /></BaseInputGroup>
|
|
<BaseInputGroup :label="$t('general.body')" required><BaseTextarea v-model="emailForm.body" /></BaseInputGroup>
|
|
</div>
|
|
<div class="flex justify-end gap-3 p-4 border-t border-line-default"><BaseButton variant="primary-outline" type="button" @click="showEmailModal = false">{{ $t('general.cancel') }}</BaseButton><BaseButton variant="primary" :loading="isSending" type="submit">{{ $t('general.send') }}</BaseButton></div>
|
|
</form>
|
|
</BaseModal>
|
|
|
|
<BaseModal :show="showCreditModal" @close="showCreditModal = false">
|
|
<template #header><div class="flex items-center justify-between w-full">{{ $t('customers.apply_credit') }}<BaseIcon name="XMarkIcon" class="w-5 h-5 cursor-pointer text-muted" @click="showCreditModal = false" /></div></template>
|
|
<div class="p-6">
|
|
<p class="mb-4 text-sm text-muted">{{ $t('customers.apply_credit_description') }}</p>
|
|
<div v-if="creditRows.length" class="space-y-3">
|
|
<div v-for="(row, index) in creditRows" :key="index" class="grid items-end gap-3 p-3 rounded-lg bg-surface-secondary md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_9rem_auto]">
|
|
<BaseInputGroup :label="$t('payments.payment')"><BaseMultiselect v-model="row.payment_id" value-prop="id" track-by="payment_number" label="payment_number" :options="creditPayments" /></BaseInputGroup>
|
|
<BaseInputGroup :label="$t('payments.invoice')"><BaseMultiselect v-model="row.invoice_id" value-prop="id" track-by="invoice_number" label="invoice_number" :options="openInvoices" /></BaseInputGroup>
|
|
<BaseInputGroup :label="$t('payments.amount')"><BaseMoney :model-value="row.amount / 100" :currency="statement?.currency ?? customer.currency" @update:model-value="setCreditAmount(index, $event)" /></BaseInputGroup>
|
|
<div class="justify-self-end">
|
|
<BaseButton type="button" size="sm" variant="gray" :aria-label="$t('payments.remove_allocation')" @click="creditRows.splice(index, 1)"><BaseIcon name="TrashIcon" /></BaseButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2 mt-4">
|
|
<BaseButton size="sm" variant="primary-outline" :disabled="!creditPayments.length || !openInvoices.length" @click="allocateCreditOldestFirst">{{ $t('payments.allocate_oldest_first') }}</BaseButton>
|
|
<BaseButton size="sm" variant="primary-outline" @click="addCreditRow"><template #left="slotProps"><BaseIcon name="PlusIcon" :class="slotProps.class" /></template>{{ $t('payments.add_allocation') }}</BaseButton>
|
|
</div>
|
|
</div>
|
|
<template #footer><div class="flex justify-end gap-3 p-4 border-t border-line-default"><BaseButton variant="primary-outline" @click="showCreditModal = false">{{ $t('general.cancel') }}</BaseButton><BaseButton variant="primary" :loading="isApplyingCredit" @click="applyCredit">{{ $t('customers.apply_credit') }}</BaseButton></div></template>
|
|
</BaseModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, reactive, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { customerService, type CustomerStatement, type CustomerStatementParams } from '@/scripts/api/services/customer.service'
|
|
import { invoiceService } from '@/scripts/api/services/invoice.service'
|
|
import { paymentService } from '@/scripts/api/services/payment.service'
|
|
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
|
import { useUserStore } from '@/scripts/stores/user.store'
|
|
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
|
import { formatDate } from '@/scripts/utils/format-date'
|
|
import type { Customer } from '@/scripts/types/domain/customer'
|
|
import type { Invoice } from '@/scripts/types/domain/invoice'
|
|
import type { Payment } from '@/scripts/types/domain/payment'
|
|
import BalanceCard from './CustomerBalanceCard.vue'
|
|
|
|
const props = defineProps<{ customer: Customer }>()
|
|
const { t } = useI18n()
|
|
const notificationStore = useNotificationStore()
|
|
const userStore = useUserStore()
|
|
const today = formatDate(new Date())
|
|
const monthStart = `${today.slice(0, 8)}01`
|
|
const filters = reactive<CustomerStatementParams>({ type: 'activity', from_date: monthStart, to_date: today, as_of: today })
|
|
const statement = ref<CustomerStatement | null>(null)
|
|
const isLoading = ref(false)
|
|
const showEmailModal = ref(false)
|
|
const isSending = ref(false)
|
|
const showCreditModal = ref(false)
|
|
const isApplyingCredit = ref(false)
|
|
const creditPayments = ref<Payment[]>([])
|
|
const openInvoices = ref<Invoice[]>([])
|
|
const creditRows = ref<Array<{ payment_id: number; invoice_id: number; amount: number }>>([])
|
|
const emailForm = reactive({ to: '', cc: '', bcc: '', subject: '', body: '' })
|
|
const statementTypes = computed(() => [
|
|
{ label: t('customers.account_activity'), value: 'activity' },
|
|
{ label: t('customers.outstanding_items'), value: 'outstanding' },
|
|
])
|
|
const statementParams = computed<CustomerStatementParams>(() => filters.type === 'activity'
|
|
? { type: 'activity', from_date: filters.from_date, to_date: filters.to_date, page: filters.page ?? 1 }
|
|
: { type: 'outstanding', as_of: filters.as_of })
|
|
const pdfUrl = computed(() => statement.value ? customerService.statementPdfUrl(props.customer.id, statementParams.value) : '')
|
|
const invoiceDueAmount = computed(() => statement.value?.invoice_due_amount ?? props.customer.invoice_due_amount ?? props.customer.due_amount ?? 0)
|
|
const availableCredit = computed(() => statement.value?.available_credit ?? props.customer.available_credit ?? 0)
|
|
const accountBalance = computed(() => statement.value?.account_balance ?? props.customer.account_balance ?? (invoiceDueAmount.value - availableCredit.value))
|
|
const canEditPayments = computed(() => userStore.hasAbilities('edit-payment'))
|
|
|
|
watch(() => props.customer.id, () => void loadStatement(), { immediate: true })
|
|
watch(() => filters.type, () => {
|
|
filters.page = 1
|
|
void loadStatement()
|
|
})
|
|
|
|
async function loadStatement(): Promise<void> {
|
|
isLoading.value = true
|
|
try {
|
|
statement.value = (await customerService.getStatement(props.customer.id, statementParams.value)).data
|
|
} catch {
|
|
statement.value = null
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
function refreshStatement(): void {
|
|
filters.page = 1
|
|
void loadStatement()
|
|
}
|
|
|
|
function changeActivityPage(page: number): void {
|
|
filters.page = page
|
|
void loadStatement()
|
|
}
|
|
|
|
async function sendStatement(): Promise<void> {
|
|
if (!emailForm.to || !emailForm.subject || !emailForm.body) return
|
|
isSending.value = true
|
|
try {
|
|
await customerService.sendStatement(props.customer.id, { ...statementParams.value, ...emailForm })
|
|
notificationStore.showNotification({ type: 'success', message: t('customers.statement_sent') })
|
|
showEmailModal.value = false
|
|
} catch (error) {
|
|
showErrorNotification(error)
|
|
} finally {
|
|
isSending.value = false
|
|
}
|
|
}
|
|
|
|
function openEmailModal(): void {
|
|
emailForm.to = props.customer.email ?? ''
|
|
emailForm.subject = t('customers.statement')
|
|
emailForm.body = t('customers.statement_email_body')
|
|
showEmailModal.value = true
|
|
}
|
|
|
|
async function openCreditModal(): Promise<void> {
|
|
showCreditModal.value = true
|
|
const [payments, invoices] = await Promise.all([
|
|
paymentService.list({ customer_id: props.customer.id, limit: 'all' }),
|
|
invoiceService.list({ customer_id: props.customer.id, limit: 'all' } as never),
|
|
])
|
|
creditPayments.value = payments.data.filter((payment) => (payment.unallocated_amount ?? 0) > 0)
|
|
openInvoices.value = (invoices.data as unknown as Invoice[]).filter(isEligibleInvoice)
|
|
if (!creditRows.value.length) addCreditRow()
|
|
}
|
|
|
|
function addCreditRow(): void {
|
|
const payment = creditPayments.value[0]
|
|
const invoice = openInvoices.value[0]
|
|
if (payment && invoice) creditRows.value.push({ payment_id: payment.id, invoice_id: invoice.id, amount: 0 })
|
|
}
|
|
|
|
function setCreditAmount(index: number, value: number): void {
|
|
if (creditRows.value[index]) creditRows.value[index].amount = Math.round(value * 100)
|
|
}
|
|
|
|
function isEligibleInvoice(invoice: Invoice): boolean {
|
|
return invoice.type === 'INVOICE' && invoice.status !== 'DRAFT' && invoice.due_amount > 0
|
|
}
|
|
|
|
function allocateCreditOldestFirst(): void {
|
|
const remainingInvoices = new Map(
|
|
[...openInvoices.value]
|
|
.sort((a, b) => (a.due_date ?? '9999-12-31').localeCompare(b.due_date ?? '9999-12-31') || a.id - b.id)
|
|
.map((invoice) => [invoice.id, invoice.due_amount]),
|
|
)
|
|
const rows: Array<{ payment_id: number; invoice_id: number; amount: number }> = []
|
|
|
|
for (const payment of [...creditPayments.value].sort((a, b) => (a.payment_date ?? '').localeCompare(b.payment_date ?? '') || a.id - b.id)) {
|
|
let remainingCredit = payment.unallocated_amount ?? 0
|
|
for (const [invoiceId, invoiceDue] of remainingInvoices) {
|
|
if (remainingCredit <= 0) break
|
|
if (invoiceDue <= 0) continue
|
|
|
|
const amount = Math.min(remainingCredit, invoiceDue)
|
|
rows.push({ payment_id: payment.id, invoice_id: invoiceId, amount })
|
|
remainingCredit -= amount
|
|
remainingInvoices.set(invoiceId, invoiceDue - amount)
|
|
}
|
|
}
|
|
|
|
creditRows.value = rows
|
|
}
|
|
|
|
async function applyCredit(): Promise<void> {
|
|
const allocations = creditRows.value.filter((row) => row.payment_id && row.invoice_id && row.amount > 0)
|
|
if (!allocations.length) return
|
|
isApplyingCredit.value = true
|
|
try {
|
|
await customerService.allocateCredit(props.customer.id, allocations)
|
|
notificationStore.showNotification({ type: 'success', message: t('customers.credit_applied') })
|
|
showCreditModal.value = false
|
|
creditRows.value = []
|
|
await loadStatement()
|
|
} catch (error) {
|
|
showErrorNotification(error)
|
|
} finally {
|
|
isApplyingCredit.value = false
|
|
}
|
|
}
|
|
|
|
function showErrorNotification(error: unknown): void {
|
|
const normalized = handleApiError(error)
|
|
const translationKey = getErrorTranslationKey(normalized.message)
|
|
notificationStore.showNotification({ type: 'error', message: translationKey ? t(translationKey) : normalized.message })
|
|
}
|
|
</script>
|