All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
368 lines
15 KiB
Plaintext
368 lines
15 KiB
Plaintext
---
|
|
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 !== 'paid' && invoice.status !== 'void' && 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">
|
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
|
Mark Paid
|
|
</button>
|
|
</form>
|
|
)}
|
|
{(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && 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">
|
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
|
Mark Accepted
|
|
</button>
|
|
</form>
|
|
)}
|
|
{(invoice.type === 'quote' && invoice.status === 'accepted') && (
|
|
<form method="POST" action={`/api/invoices/${invoice.id}/convert`}>
|
|
<button type="submit" class="btn btn-primary">
|
|
<Icon name="heroicons:document-duplicate" class="w-5 h-5" />
|
|
Convert to Invoice
|
|
</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>
|
|
{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>
|
|
{client.email && <div class="text-base-content/70">{client.email}</div>}
|
|
{client.phone && <div class="text-base-content/70">{client.phone}</div>}
|
|
{(client.street || client.city || client.state || client.zip || client.country) && (
|
|
<div class="text-sm text-base-content/70 mt-2 space-y-0.5">
|
|
{client.street && <div>{client.street}</div>}
|
|
{(client.city || client.state || client.zip) && (
|
|
<div>
|
|
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
|
</div>
|
|
)}
|
|
{client.country && <div>{client.country}</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div class="italic text-base-content/40">Client deleted</div>
|
|
)}
|
|
</div>
|
|
|
|
<!-- Items Table -->
|
|
<div class="mb-8">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full min-w-[600px]">
|
|
<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>
|
|
</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 || isDraft) && (
|
|
<div class="flex justify-between text-sm items-center group">
|
|
<span class="text-base-content/60 flex items-center gap-2">
|
|
Tax ({invoice.taxRate ?? 0}%)
|
|
{isDraft && (
|
|
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
|
|
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</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-primary">Edit Details</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tax Modal -->
|
|
<dialog id="tax_modal" class="modal">
|
|
<div class="modal-box">
|
|
<h3 class="font-bold text-lg">Update Tax Rate</h3>
|
|
<p class="py-4">Enter the tax percentage to apply to the subtotal.</p>
|
|
<form method="POST" action={`/api/invoices/${invoice.id}/update-tax`}>
|
|
<div class="form-control mb-6">
|
|
<label class="label">
|
|
<span class="label-text">Tax Rate (%)</span>
|
|
</label>
|
|
<input
|
|
type="number"
|
|
name="taxRate"
|
|
step="0.01"
|
|
min="0"
|
|
max="100"
|
|
class="input input-bordered w-full"
|
|
value={invoice.taxRate ?? 0}
|
|
required
|
|
/>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button type="button" class="btn" onclick="document.getElementById('tax_modal').close()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Update</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button>close</button>
|
|
</form>
|
|
</dialog>
|
|
</DashboardLayout>
|