This commit is contained in:
312
src/pages/dashboard/invoices/[id].astro
Normal file
312
src/pages/dashboard/invoices/[id].astro
Normal file
@@ -0,0 +1,312 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
|
||||
if (!user || !id) {
|
||||
return Astro.redirect('/dashboard/invoices');
|
||||
}
|
||||
|
||||
// Fetch invoice with related data
|
||||
const invoiceResult = await db.select({
|
||||
invoice: invoices,
|
||||
client: clients,
|
||||
organization: organizations,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.innerJoin(organizations, eq(invoices.organizationId, organizations.id))
|
||||
.where(eq(invoices.id, id))
|
||||
.get();
|
||||
|
||||
if (!invoiceResult) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
const { invoice, client, organization } = invoiceResult;
|
||||
|
||||
// Verify access
|
||||
const membership = await db.select()
|
||||
.from(members)
|
||||
.where(and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
))
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return Astro.redirect('/dashboard');
|
||||
}
|
||||
|
||||
// Fetch items
|
||||
const items = await db.select()
|
||||
.from(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
||||
.all();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: invoice.currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const isDraft = invoice.status === 'draft';
|
||||
---
|
||||
|
||||
<DashboardLayout title={`${invoice.number} - Chronus`}>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs btn-square">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<div class={`badge ${
|
||||
invoice.status === 'paid' || invoice.status === 'accepted' ? 'badge-success' :
|
||||
invoice.status === 'sent' ? 'badge-info' :
|
||||
invoice.status === 'void' || invoice.status === 'declined' ? 'badge-error' :
|
||||
'badge-ghost'
|
||||
} uppercase font-bold tracking-wider`}>
|
||||
{invoice.status}
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold">{invoice.number}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{isDraft && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="sent" />
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:paper-airplane" class="w-5 h-5" />
|
||||
Mark Sent
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.status === 'sent' && invoice.type === 'invoice') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="paid" />
|
||||
<button type="submit" class="btn btn-success text-white">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Mark Paid
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.status === 'sent' && invoice.type === 'quote') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="accepted" />
|
||||
<button type="submit" class="btn btn-success text-white">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Mark Accepted
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div role="button" tabindex="0" class="btn btn-square btn-ghost border border-base-300">
|
||||
<Icon name="heroicons:ellipsis-horizontal" class="w-6 h-6" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content z-1 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<li>
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
|
||||
Edit Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/api/invoices/${invoice.id}/generate`} download>
|
||||
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" onclick="window.print()">
|
||||
<Icon name="heroicons:printer" class="w-4 h-4" />
|
||||
Print
|
||||
</button>
|
||||
</li>
|
||||
{invoice.status !== 'void' && invoice.status !== 'draft' && (
|
||||
<li>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="void" />
|
||||
<button type="submit" class="text-error">
|
||||
<Icon name="heroicons:x-circle" class="w-4 h-4" />
|
||||
Void
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<form method="POST" action="/api/invoices/delete" onsubmit="return confirm('Are you sure?');">
|
||||
<input type="hidden" name="id" value={invoice.id} />
|
||||
<button type="submit" class="text-error">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Paper -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 print:shadow-none print:border-none">
|
||||
<div class="card-body p-8 sm:p-12">
|
||||
<!-- Header Section -->
|
||||
<div class="flex flex-col sm:flex-row justify-between gap-8 mb-12">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-primary mb-1">{organization.name}</h2>
|
||||
{(organization.street || organization.city || organization.state || organization.zip || organization.country) && (
|
||||
<div class="text-sm opacity-70 space-y-0.5">
|
||||
{organization.street && <div>{organization.street}</div>}
|
||||
{(organization.city || organization.state || organization.zip) && (
|
||||
<div>
|
||||
{[organization.city, organization.state, organization.zip].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{organization.country && <div>{organization.country}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-4xl font-light text-base-content/30 uppercase tracking-widest mb-4">
|
||||
{invoice.type}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<div class="text-base-content/60">Number:</div>
|
||||
<div class="font-mono font-bold">{invoice.number}</div>
|
||||
<div class="text-base-content/60">Date:</div>
|
||||
<div>{invoice.issueDate.toLocaleDateString()}</div>
|
||||
<div class="text-base-content/60">Due Date:</div>
|
||||
<div>{invoice.dueDate.toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bill To -->
|
||||
<div class="mb-12">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Bill To</div>
|
||||
{client ? (
|
||||
<div>
|
||||
<div class="font-bold text-lg">{client.name}</div>
|
||||
<div class="text-base-content/70">{client.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="italic text-base-content/40">Client deleted</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="mb-8">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-base-200 text-left text-xs font-bold uppercase tracking-wider text-base-content/40">
|
||||
<th class="py-3">Description</th>
|
||||
<th class="py-3 text-right w-24">Qty</th>
|
||||
<th class="py-3 text-right w-32">Price</th>
|
||||
<th class="py-3 text-right w-32">Amount</th>
|
||||
{isDraft && <th class="py-3 w-10"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-base-200">
|
||||
{items.map(item => (
|
||||
<tr>
|
||||
<td class="py-4">{item.description}</td>
|
||||
<td class="py-4 text-right">{item.quantity}</td>
|
||||
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td>
|
||||
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td>
|
||||
{isDraft && (
|
||||
<td class="py-4 text-right">
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<button type="submit" class="btn btn-ghost btn-xs btn-square text-error opacity-50 hover:opacity-100">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colspan={isDraft ? 5 : 4} class="py-8 text-center text-base-content/40 italic">
|
||||
No items added yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Item Form (Only if Draft) -->
|
||||
{isDraft && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200/50 p-4 rounded-lg mb-8 border border-base-300/50">
|
||||
<h4 class="text-sm font-bold mb-3">Add Item</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-4 items-end">
|
||||
<div class="sm:col-span-6">
|
||||
<label class="label label-text text-xs pt-0">Description</label>
|
||||
<input type="text" name="description" class="input input-sm input-bordered w-full" required placeholder="Service or product..." />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label label-text text-xs pt-0">Qty</label>
|
||||
<input type="number" name="quantity" step="0.01" class="input input-sm input-bordered w-full" required value="1" />
|
||||
</div>
|
||||
<div class="sm:col-span-3">
|
||||
<label class="label label-text text-xs pt-0">Unit Price ({invoice.currency})</label>
|
||||
<input type="number" name="unitPrice" step="0.01" class="input input-sm input-bordered w-full" required placeholder="0.00" />
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-full">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="flex justify-end">
|
||||
<div class="w-64 space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
{(invoice.taxRate ?? 0) > 0 && (
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Tax ({invoice.taxRate}%)</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span class="text-primary">{formatCurrency(invoice.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{invoice.notes && (
|
||||
<div class="mt-12 pt-8 border-t border-base-200">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Notes</div>
|
||||
<div class="text-sm whitespace-pre-wrap opacity-80">{invoice.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Notes (Draft Only) - Simplistic approach */}
|
||||
{isDraft && !invoice.notes && (
|
||||
<div class="mt-8 text-center">
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-ghost">Add Notes</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
Reference in New Issue
Block a user