FINISHED
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s

This commit is contained in:
2026-01-17 15:56:25 -07:00
parent 3734b2693a
commit 0cd77677f2
36 changed files with 2012 additions and 202 deletions

View File

@@ -58,7 +58,7 @@ if (!client) return Astro.redirect('/dashboard/clients');
name="name"
value={client.name}
placeholder="Acme Corp"
class="input input-bordered"
class="input input-bordered w-full"
required
/>
</div>
@@ -72,11 +72,101 @@ if (!client) return Astro.redirect('/dashboard/clients');
id="email"
name="email"
value={client.email || ''}
placeholder="contact@acme.com"
class="input input-bordered"
placeholder="jason.borne@cia.com"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone (optional)</span>
</label>
<input
type="tel"
id="phone"
name="phone"
value={client.phone || ''}
placeholder="+1 (780) 420-1337"
class="input input-bordered w-full"
/>
</div>
<div class="divider">Address Details</div>
<div class="form-control">
<label class="label" for="street">
<span class="label-text">Street Address (optional)</span>
</label>
<input
type="text"
id="street"
name="street"
value={client.street || ''}
placeholder="123 Business Rd"
class="input input-bordered w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="city">
<span class="label-text">City (optional)</span>
</label>
<input
type="text"
id="city"
name="city"
value={client.city || ''}
placeholder="Edmonton"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State / Province (optional)</span>
</label>
<input
type="text"
id="state"
name="state"
value={client.state || ''}
placeholder="AB"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="zip">
<span class="label-text">Zip / Postal Code (optional)</span>
</label>
<input
type="text"
id="zip"
name="zip"
value={client.zip || ''}
placeholder="10001"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="country">
<span class="label-text">Country (optional)</span>
</label>
<input
type="text"
id="country"
name="country"
value={client.country || ''}
placeholder="Canada"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="card-actions justify-between mt-6">
<button
type="button"

View File

@@ -86,12 +86,34 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
{client.email && (
<div class="flex items-center gap-2 text-base-content/70 mb-4">
<Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</div>
)}
<div class="space-y-2 mb-4">
{client.email && (
<div class="flex items-center gap-2 text-base-content/70">
<Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</div>
)}
{client.phone && (
<div class="flex items-center gap-2 text-base-content/70">
<Icon name="heroicons:phone" class="w-4 h-4" />
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
</div>
)}
{(client.street || client.city || client.state || client.zip || client.country) && (
<div class="flex items-start gap-2 text-base-content/70">
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
<div class="text-sm 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>
</div>
<div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">

View File

@@ -8,20 +8,20 @@ if (!user) return Astro.redirect('/login');
<DashboardLayout title="New Client - Chronus">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Client Name</span>
</label>
<input
type="text"
<input
type="text"
id="name"
name="name"
placeholder="Acme Corp"
class="input input-bordered"
required
name="name"
placeholder="Acme Corp"
class="input input-bordered w-full"
required
/>
</div>
@@ -29,15 +29,99 @@ if (!user) return Astro.redirect('/login');
<label class="label" for="email">
<span class="label-text">Email (optional)</span>
</label>
<input
type="email"
<input
type="email"
id="email"
name="email"
placeholder="contact@acme.com"
class="input input-bordered"
name="email"
placeholder="jason.borne@cia.com"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone (optional)</span>
</label>
<input
type="tel"
id="phone"
name="phone"
placeholder="+1 (780) 420-1337"
class="input input-bordered w-full"
/>
</div>
<div class="divider">Address Details</div>
<div class="form-control">
<label class="label" for="street">
<span class="label-text">Street Address (optional)</span>
</label>
<input
type="text"
id="street"
name="street"
placeholder="123 Business Rd"
class="input input-bordered w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="city">
<span class="label-text">City (optional)</span>
</label>
<input
type="text"
id="city"
name="city"
placeholder="Edmonton"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State / Province (optional)</span>
</label>
<input
type="text"
id="state"
name="state"
placeholder="AB"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="zip">
<span class="label-text">Zip / Postal Code (optional)</span>
</label>
<input
type="text"
id="zip"
name="zip"
placeholder="10001"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="country">
<span class="label-text">Country (optional)</span>
</label>
<input
type="text"
id="country"
name="country"
placeholder="Canada"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="card-actions justify-end mt-6">
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Client</button>

View File

@@ -90,24 +90,32 @@ const isDraft = invoice.status === 'draft';
</button>
</form>
)}
{(invoice.status === 'sent' && invoice.type === 'invoice') && (
{(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 text-white">
<button type="submit" class="btn btn-success">
<Icon name="heroicons:check" class="w-5 h-5" />
Mark Paid
</button>
</form>
)}
{(invoice.status === 'sent' && invoice.type === 'quote') && (
{(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 text-white">
<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" />
@@ -125,12 +133,6 @@ const isDraft = invoice.status === 'draft';
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`}>
@@ -196,7 +198,19 @@ const isDraft = invoice.status === 'draft';
{client ? (
<div>
<div class="font-bold text-lg">{client.name}</div>
<div class="text-base-content/70">{client.email}</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>
@@ -280,9 +294,16 @@ const isDraft = invoice.status === 'draft';
<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>
{((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>
)}
@@ -305,10 +326,42 @@ const isDraft = invoice.status === 'draft';
{/* 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>
<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>

View File

@@ -99,7 +99,9 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Due Date</span>
<span class="label-text font-semibold">
{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}
</span>
</label>
<input
type="date"
@@ -128,7 +130,7 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
</div>
<!-- Notes -->
<div class="form-control">
<div class="form-control flex flex-col">
<label class="label">
<span class="label-text font-semibold">Notes / Terms</span>
</label>

View File

@@ -109,7 +109,7 @@ const getStatusColor = (status: string) => {
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-0">
<div class="overflow-x-auto">
<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">

View File

@@ -47,7 +47,9 @@ if (lastInvoice) {
const match = lastInvoice.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
const prefix = lastInvoice.number.replace(match[0], '');
let prefix = lastInvoice.number.replace(match[0], '');
// Ensure we don't carry over an EST- prefix to an invoice
if (prefix === 'EST-') prefix = 'INV-';
nextInvoiceNumber = prefix + num.toString().padStart(match[0].length, '0');
}
}
@@ -68,7 +70,9 @@ if (lastQuote) {
const match = lastQuote.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
const prefix = lastQuote.number.replace(match[0], '');
let prefix = lastQuote.number.replace(match[0], '');
// Ensure we don't carry over an INV- prefix to a quote
if (prefix === 'INV-') prefix = 'EST-';
nextQuoteNumber = prefix + num.toString().padStart(match[0].length, '0');
}
}
@@ -167,7 +171,7 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Due Date</span>
<span class="label-text font-semibold" id="dueDateLabel">Due Date</span>
</label>
<input
type="date"
@@ -212,22 +216,26 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
// Update number based on document type
const typeRadios = document.querySelectorAll('input[name="type"]');
const numberInput = document.getElementById('documentNumber') as HTMLInputElement | null;
const dueDateLabel = document.getElementById('dueDateLabel');
if (numberInput) {
const invoiceNumber = numberInput.dataset.invoiceNumber || 'INV-001';
const quoteNumber = numberInput.dataset.quoteNumber || 'EST-001';
const invoiceNumber = numberInput?.dataset.invoiceNumber || 'INV-001';
const quoteNumber = numberInput?.dataset.quoteNumber || 'EST-001';
typeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (numberInput) {
if (target.value === 'quote') {
numberInput.value = quoteNumber;
} else if (target.value === 'invoice') {
numberInput.value = invoiceNumber;
}
typeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (numberInput) {
if (target.value === 'quote') {
numberInput.value = quoteNumber;
} else if (target.value === 'invoice') {
numberInput.value = invoiceNumber;
}
});
}
if (dueDateLabel) {
dueDateLabel.textContent = target.value === 'quote' ? 'Valid Until' : 'Due Date';
}
});
}
});
</script>

View File

@@ -67,9 +67,51 @@ const successType = url.searchParams.get('success');
</div>
)}
<form action="/api/organizations/update-name" method="POST" class="space-y-4">
<form
action="/api/organizations/update-name"
method="POST"
class="space-y-4"
enctype="multipart/form-data"
>
<input type="hidden" name="organizationId" value={organization.id} />
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Team Logo</span>
</div>
<div class="flex items-center gap-6">
<div class="avatar placeholder">
<div class="bg-base-200 text-neutral-content rounded-xl w-24 border border-base-300 flex items-center justify-center overflow-hidden">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt={organization.name}
class="w-full h-full object-cover"
/>
) : (
<Icon
name="heroicons:photo"
class="w-8 h-8 opacity-40 text-base-content"
/>
)}
</div>
</div>
<div>
<input
type="file"
name="logo"
accept="image/png, image/jpeg"
class="file-input file-input-bordered w-full max-w-xs"
/>
<div class="text-xs text-base-content/60 mt-2">
Upload a company logo (PNG, JPG).
<br />
Will be displayed on invoices and quotes.
</div>
</div>
</div>
</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Team Name</span>