Files
chronus/src/pages/dashboard/invoices/index.astro
Atridad Lahiji b5ac2e0608
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m0s
Oops
2026-01-20 01:21:56 -07:00

353 lines
15 KiB
Plaintext

---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { invoices, clients, members } from '../../../db/schema';
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const currentTeamIdResolved = userMembership.organizationId;
// Get filter parameters
const currentYear = new Date().getFullYear();
const yearParam = Astro.url.searchParams.get('year');
const selectedYear = yearParam === 'current' || !yearParam ? 'current' : parseInt(yearParam);
const selectedType = Astro.url.searchParams.get('type') || 'all';
const selectedStatus = Astro.url.searchParams.get('status') || 'all';
const sortBy = Astro.url.searchParams.get('sort') || 'date-desc';
// Fetch all invoices for the organization (for year dropdown)
const allInvoicesRaw = await db.select({
invoice: invoices,
client: clients,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(eq(invoices.organizationId, currentTeamIdResolved))
.all();
// Get unique years from invoices
const availableYears = [...new Set(allInvoicesRaw.map(i => i.invoice.issueDate.getFullYear()))].sort((a, b) => b - a);
// Ensure current year is in the list
if (!availableYears.includes(currentYear)) {
availableYears.unshift(currentYear);
}
// Filter by year
const yearStart = selectedYear === 'current' ? new Date(currentYear, 0, 1) : new Date(selectedYear, 0, 1);
const yearEnd = selectedYear === 'current' ? new Date() : new Date(selectedYear, 11, 31, 23, 59, 59);
let filteredInvoices = allInvoicesRaw.filter(i => {
const issueDate = i.invoice.issueDate;
return issueDate >= yearStart && issueDate <= yearEnd;
});
// Filter by type
if (selectedType !== 'all') {
filteredInvoices = filteredInvoices.filter(i => i.invoice.type === selectedType);
}
// Filter by status
if (selectedStatus !== 'all') {
filteredInvoices = filteredInvoices.filter(i => i.invoice.status === selectedStatus);
}
// Sort invoices
const allInvoices = filteredInvoices.sort((a, b) => {
switch (sortBy) {
case 'date-desc':
return b.invoice.issueDate.getTime() - a.invoice.issueDate.getTime();
case 'date-asc':
return a.invoice.issueDate.getTime() - b.invoice.issueDate.getTime();
case 'amount-desc':
return b.invoice.total - a.invoice.total;
case 'amount-asc':
return a.invoice.total - b.invoice.total;
case 'number-desc':
return b.invoice.number.localeCompare(a.invoice.number);
case 'number-asc':
return a.invoice.number.localeCompare(b.invoice.number);
default:
return b.invoice.issueDate.getTime() - a.invoice.issueDate.getTime();
}
});
// Calculate stats for the selected year
const yearInvoices = allInvoicesRaw.filter(i => {
const issueDate = i.invoice.issueDate;
return issueDate >= yearStart && issueDate <= yearEnd;
});
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'paid': return 'badge-success';
case 'accepted': return 'badge-success';
case 'sent': return 'badge-info';
case 'draft': return 'badge-ghost';
case 'void': return 'badge-error';
case 'declined': return 'badge-error';
default: return 'badge-ghost';
}
};
---
<DashboardLayout title="Invoices & Quotes - Chronus">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 class="text-3xl font-bold">Invoices & Quotes</h1>
<p class="text-base-content/60 mt-1">Manage your billing and estimates</p>
</div>
<a href="/dashboard/invoices/new" class="btn btn-primary">
<Icon name="heroicons:plus" class="w-5 h-5" />
Create New
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:document-text" class="w-8 h-8" />
</div>
<div class="stat-title">Total Invoices</div>
<div class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div>
</div>
</div>
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" />
</div>
<div class="stat-title">Open Quotes</div>
<div class="stat-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
<div class="stat-desc">Waiting for approval</div>
</div>
</div>
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-success">
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
</div>
<div class="stat-title">Total Revenue</div>
<div class="stat-value text-success">
{formatCurrency(yearInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
</div>
<div class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Year</span>
</label>
<select name="year" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
{availableYears.map(year => (
<option value={year} selected={year === selectedYear}>{year}</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Type</span>
</label>
<select name="type" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="all" selected={selectedType === 'all'}>All Types</option>
<option value="invoice" selected={selectedType === 'invoice'}>Invoices</option>
<option value="quote" selected={selectedType === 'quote'}>Quotes</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select name="status" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="all" selected={selectedStatus === 'all'}>All Statuses</option>
<option value="draft" selected={selectedStatus === 'draft'}>Draft</option>
<option value="sent" selected={selectedStatus === 'sent'}>Sent</option>
<option value="paid" selected={selectedStatus === 'paid'}>Paid</option>
<option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option>
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
<option value="void" selected={selectedStatus === 'void'}>Void</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Sort By</span>
</label>
<select name="sort" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option>
<option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option>
<option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option>
<option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option>
<option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option>
<option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option>
</select>
</div>
</form>
{(selectedYear !== 'current' || selectedType !== 'all' || selectedStatus !== 'all' || sortBy !== 'date-desc') && (
<div class="mt-4">
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
Clear Filters
</a>
</div>
)}
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-0">
<div class="px-6 py-4 border-b border-base-200 bg-base-200/30">
<p class="text-sm text-base-content/70">
Showing <span class="font-semibold text-base-content">{allInvoices.length}</span>
{allInvoices.length === 1 ? 'result' : 'results'}
{selectedYear === 'current' ? ` for ${currentYear} (year to date)` : ` for ${selectedYear}`}
</p>
</div>
<div class="overflow-x-auto md:overflow-visible pb-32 md:pb-0">
<table class="table table-zebra">
<thead>
<tr class="bg-base-200/50">
<th>Number</th>
<th>Client</th>
<th>Date</th>
<th>Due Date</th>
<th>Amount</th>
<th>Status</th>
<th>Type</th>
<th></th>
</tr>
</thead>
<tbody>
{allInvoices.length === 0 ? (
<tr>
<td colspan="8" class="text-center py-8 text-base-content/60">
No invoices or quotes found. Create one to get started.
</td>
</tr>
) : (
allInvoices.map(({ invoice, client }) => (
<tr class="hover:bg-base-200/50 transition-colors">
<td class="font-mono font-medium">
<a href={`/dashboard/invoices/${invoice.id}`} class="link link-hover text-primary">
{invoice.number}
</a>
</td>
<td>
{client ? (
<div class="font-medium">{client.name}</div>
) : (
<span class="text-base-content/40 italic">Deleted Client</span>
)}
</td>
<td>{invoice.issueDate.toLocaleDateString()}</td>
<td>{invoice.dueDate.toLocaleDateString()}</td>
<td class="font-mono font-medium">
{formatCurrency(invoice.total, invoice.currency)}
</td>
<td>
<div class={`badge ${getStatusColor(invoice.status)} badge-sm uppercase font-bold tracking-wider`}>
{invoice.status}
</div>
</td>
<td>
<span class="capitalize text-sm">{invoice.type}</span>
</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<div role="button" tabindex="0" class="btn btn-ghost btn-sm btn-square">
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
</div>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-200 z-100">
<li>
<a href={`/dashboard/invoices/${invoice.id}`}>
<Icon name="heroicons:eye" class="w-4 h-4" />
View Details
</a>
</li>
<li>
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
Edit
</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 === 'draft' && (
<li>
<form method="POST" action={`/api/invoices/${invoice.id}/status`} class="w-full">
<input type="hidden" name="status" value="sent" />
<button type="submit" class="w-full justify-start">
<Icon name="heroicons:paper-airplane" class="w-4 h-4" />
Mark as Sent
</button>
</form>
</li>
)}
<div class="divider my-1"></div>
<li>
<form method="POST" action={`/api/invoices/delete`} onsubmit="return confirm('Are you sure? This action cannot be undone.');" class="w-full">
<input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="w-full justify-start text-error hover:bg-error/10">
<Icon name="heroicons:trash" class="w-4 h-4" />
Delete
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>