Install barryvdh/laravel-dompdf, create professional invoice Blade template with EZSCALE branding, add download endpoints for customer billing and admin invoice pages. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
408 lines
13 KiB
Vue
408 lines
13 KiB
Vue
<script lang="ts" setup>
|
|
import { Link, useForm } from '@inertiajs/vue3'
|
|
import { computed, ref } from 'vue'
|
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
|
import { resolveInvoiceStatusColor, resolveTransactionStatusColor, formatPrice } from '@/utils/resolvers'
|
|
|
|
interface InvoiceUser {
|
|
id: number
|
|
name: string
|
|
email: string
|
|
status: string
|
|
}
|
|
|
|
interface InvoiceLineItem {
|
|
id: number
|
|
description: string
|
|
amount: string
|
|
quantity: number
|
|
}
|
|
|
|
interface PaymentTransactionItem {
|
|
id: number
|
|
gateway: string
|
|
gateway_transaction_id: string | null
|
|
amount: string
|
|
currency: string
|
|
status: string
|
|
payment_method: string | null
|
|
description: string | null
|
|
created_at: string
|
|
}
|
|
|
|
interface InvoiceDetail {
|
|
id: number
|
|
user_id: number
|
|
number: string
|
|
total: string
|
|
tax: string
|
|
currency: string
|
|
status: string
|
|
gateway: string | null
|
|
gateway_invoice_id: string | null
|
|
invoice_pdf: string | null
|
|
due_date: string | null
|
|
paid_at: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
user: InvoiceUser | null
|
|
items: InvoiceLineItem[]
|
|
payment_transactions: PaymentTransactionItem[]
|
|
}
|
|
|
|
interface Props {
|
|
invoice: InvoiceDetail
|
|
}
|
|
|
|
defineOptions({ layout: AdminLayout })
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const voidDialog = ref<boolean>(false)
|
|
const voidForm = useForm({})
|
|
|
|
const subtotal = computed<number>(() => {
|
|
return props.invoice.items.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.amount) * item.quantity)
|
|
}, 0)
|
|
})
|
|
|
|
function submitVoid(): void {
|
|
voidForm.post(`/invoices/${props.invoice.id}/void`, {
|
|
preserveScroll: true,
|
|
onSuccess: () => { voidDialog.value = false },
|
|
})
|
|
}
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
if (!dateStr) return '---'
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
|
}
|
|
|
|
function formatDateTime(dateStr: string | null): string {
|
|
if (!dateStr) return '---'
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<!-- Header -->
|
|
<div class="d-flex align-center justify-space-between mb-6">
|
|
<div class="d-flex align-center gap-4">
|
|
<Link href="/invoices">
|
|
<VBtn variant="text" icon="tabler-arrow-left" size="small" />
|
|
</Link>
|
|
<div>
|
|
<div class="d-flex align-center gap-2">
|
|
<span class="text-h4 font-weight-bold">Invoice {{ invoice.number }}</span>
|
|
<VChip
|
|
:color="resolveInvoiceStatusColor(invoice.status)"
|
|
size="small"
|
|
class="text-capitalize"
|
|
>
|
|
{{ invoice.status }}
|
|
</VChip>
|
|
</div>
|
|
<div class="text-body-2 text-medium-emphasis mt-1">
|
|
{{ invoice.user?.name ?? 'Unknown Customer' }} · {{ invoice.user?.email ?? '' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<VBtn
|
|
color="info"
|
|
variant="tonal"
|
|
:href="`/invoices/${invoice.id}/download`"
|
|
>
|
|
<VIcon icon="tabler-download" start />
|
|
Download PDF
|
|
</VBtn>
|
|
|
|
<VBtn
|
|
v-if="invoice.status !== 'void' && invoice.status !== 'paid'"
|
|
color="error"
|
|
variant="tonal"
|
|
:disabled="voidForm.processing"
|
|
@click="voidDialog = true"
|
|
>
|
|
<VIcon icon="tabler-ban" start />
|
|
Void Invoice
|
|
</VBtn>
|
|
</div>
|
|
</div>
|
|
|
|
<VRow>
|
|
<!-- Invoice Info -->
|
|
<VCol cols="12" lg="4">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-file-invoice" size="22" />
|
|
<span>Invoice Details</span>
|
|
</VCardTitle>
|
|
|
|
<VCardText>
|
|
<VList density="compact" class="pa-0">
|
|
<VListItem>
|
|
<template #prepend>
|
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Number</span>
|
|
</template>
|
|
<VListItemTitle class="text-body-2 font-weight-medium">
|
|
{{ invoice.number }}
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Gateway</span>
|
|
</template>
|
|
<VListItemTitle class="text-body-2 text-capitalize">
|
|
{{ invoice.gateway ?? 'N/A' }}
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Currency</span>
|
|
</template>
|
|
<VListItemTitle class="text-body-2 text-uppercase">
|
|
{{ invoice.currency }}
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Created</span>
|
|
</template>
|
|
<VListItemTitle class="text-body-2">
|
|
{{ formatDate(invoice.created_at) }}
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Due Date</span>
|
|
</template>
|
|
<VListItemTitle class="text-body-2">
|
|
{{ formatDate(invoice.due_date) }}
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Paid Date</span>
|
|
</template>
|
|
<VListItemTitle class="text-body-2">
|
|
{{ formatDate(invoice.paid_at) }}
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
</VList>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Customer Card -->
|
|
<VCard class="mt-4">
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-user" size="22" />
|
|
<span>Customer</span>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="invoice.user">
|
|
<div class="d-flex align-center gap-3">
|
|
<VAvatar color="primary" variant="tonal" size="40">
|
|
<span class="text-body-1 font-weight-semibold">
|
|
{{ invoice.user.name.charAt(0).toUpperCase() }}
|
|
</span>
|
|
</VAvatar>
|
|
<div>
|
|
<div class="text-body-1 font-weight-medium">
|
|
{{ invoice.user.name }}
|
|
</div>
|
|
<div class="text-body-2 text-medium-emphasis">
|
|
{{ invoice.user.email }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<Link :href="`/customers/${invoice.user.id}`">
|
|
<VBtn variant="tonal" size="small" color="primary" block>
|
|
View Customer
|
|
</VBtn>
|
|
</Link>
|
|
</div>
|
|
</VCardText>
|
|
</VCard>
|
|
</VCol>
|
|
|
|
<!-- Invoice Items & Totals -->
|
|
<VCol cols="12" lg="8">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-list" size="22" />
|
|
<span>Invoice Items</span>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="invoice.items.length === 0" class="text-center py-8">
|
|
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
No items on this invoice.
|
|
</div>
|
|
</VCardText>
|
|
|
|
<template v-else>
|
|
<VTable density="comfortable">
|
|
<thead>
|
|
<tr>
|
|
<th>Description</th>
|
|
<th class="text-center">
|
|
Qty
|
|
</th>
|
|
<th class="text-end">
|
|
Unit Price
|
|
</th>
|
|
<th class="text-end">
|
|
Total
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in invoice.items" :key="item.id">
|
|
<td class="text-body-2">
|
|
{{ item.description }}
|
|
</td>
|
|
<td class="text-center text-body-2">
|
|
{{ item.quantity }}
|
|
</td>
|
|
<td class="text-end text-body-2">
|
|
{{ formatPrice(item.amount) }}
|
|
</td>
|
|
<td class="text-end text-body-2 font-weight-medium">
|
|
{{ formatPrice(parseFloat(item.amount) * item.quantity) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
|
|
<!-- Totals -->
|
|
<VDivider />
|
|
<VCardText>
|
|
<div class="d-flex flex-column align-end ga-2">
|
|
<div class="d-flex align-center ga-6" style="min-width: 200px;">
|
|
<span class="text-body-2 text-medium-emphasis">Subtotal</span>
|
|
<VSpacer />
|
|
<span class="text-body-2">{{ formatPrice(subtotal) }}</span>
|
|
</div>
|
|
<div class="d-flex align-center ga-6" style="min-width: 200px;">
|
|
<span class="text-body-2 text-medium-emphasis">Tax</span>
|
|
<VSpacer />
|
|
<span class="text-body-2">{{ formatPrice(invoice.tax) }}</span>
|
|
</div>
|
|
<VDivider class="my-1" style="min-width: 200px;" />
|
|
<div class="d-flex align-center ga-6" style="min-width: 200px;">
|
|
<span class="text-body-1 font-weight-bold">Total</span>
|
|
<VSpacer />
|
|
<span class="text-body-1 font-weight-bold">{{ formatPrice(invoice.total) }}</span>
|
|
</div>
|
|
</div>
|
|
</VCardText>
|
|
</template>
|
|
</VCard>
|
|
|
|
<!-- Payment Transactions -->
|
|
<VCard class="mt-4">
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-credit-card" size="22" />
|
|
<span>Payment Transactions</span>
|
|
<VSpacer />
|
|
<VChip size="small" color="secondary" variant="tonal">
|
|
{{ invoice.payment_transactions.length }}
|
|
</VChip>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="invoice.payment_transactions.length === 0" class="text-center py-8">
|
|
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
No payment transactions recorded.
|
|
</div>
|
|
</VCardText>
|
|
|
|
<VTable v-else density="comfortable" hover>
|
|
<thead>
|
|
<tr>
|
|
<th>Gateway</th>
|
|
<th>Method</th>
|
|
<th>Status</th>
|
|
<th class="text-end">
|
|
Amount
|
|
</th>
|
|
<th>Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="txn in invoice.payment_transactions" :key="txn.id">
|
|
<td class="text-body-2 text-capitalize">
|
|
{{ txn.gateway }}
|
|
</td>
|
|
<td class="text-body-2">
|
|
{{ txn.payment_method ?? '---' }}
|
|
</td>
|
|
<td>
|
|
<VChip
|
|
:color="resolveTransactionStatusColor(txn.status)"
|
|
size="small"
|
|
class="text-capitalize"
|
|
>
|
|
{{ txn.status }}
|
|
</VChip>
|
|
</td>
|
|
<td class="text-end text-body-2 font-weight-medium">
|
|
{{ formatPrice(txn.amount) }}
|
|
</td>
|
|
<td class="text-body-2">
|
|
{{ formatDateTime(txn.created_at) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<!-- Void Confirmation Dialog -->
|
|
<VDialog v-model="voidDialog" max-width="500" persistent>
|
|
<VCard>
|
|
<VCardTitle class="text-h5 pa-5">
|
|
Void Invoice
|
|
</VCardTitle>
|
|
<VCardText class="px-5 pb-2">
|
|
Are you sure you want to void invoice <strong>{{ invoice.number }}</strong>?
|
|
This will mark the invoice as void and it can no longer be collected on.
|
|
</VCardText>
|
|
<VCardActions class="pa-5">
|
|
<VSpacer />
|
|
<VBtn variant="text" :disabled="voidForm.processing" @click="voidDialog = false">
|
|
Cancel
|
|
</VBtn>
|
|
<VBtn
|
|
color="error"
|
|
variant="flat"
|
|
:loading="voidForm.processing"
|
|
@click="submitVoid"
|
|
>
|
|
Void Invoice
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
</div>
|
|
</template>
|