Files
website/website/resources/ts/Pages/Admin/Invoices/Show.vue
Claude Dev 69e0882c81 Add invoice PDF generation with dompdf
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>
2026-02-09 20:17:34 -05:00

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' }} &middot; {{ 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>