2026-01-26 10:12:01 -05:00

550 lines
19 KiB
Svelte

// frontend/src/routes/invoices/[id]/+page.svelte
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
invoiceService,
customerService,
type Invoice,
type Customer,
type Account,
type Project,
type Revenue
} from '$lib/api.js';
import { profile, isAuthenticated } from '$lib/auth.js';
import { goto } from '$app/navigation';
import {format, parseISO} from "date-fns";
// Get invoice ID from URL
const invoiceId = page.params.id;
// STATES
let invoice: Invoice | null = $state(null);
let customer: Customer | null = $state(null);
let accounts: Account[] = $state([]);
let projects: Project[] = $state([]);
let revenues: Revenue[] = $state([]);
let loading = $state(true);
let error = $state('');
let isEditing = $state(false);
let isAdmin = $state(false);
// STATUS OPTIONS
const statusOptions = [
{ value: 'draft', label: 'Draft' },
{ value: 'sent', label: 'Sent' },
{ value: 'paid', label: 'Paid' },
{ value: 'overdue', label: 'Overdue' },
{ value: 'cancelled', label: 'Cancelled' }
];
// PAYMENT OPTIONS
const paymentOptions = [
{ value: 'check', label: 'Check' },
{ value: 'credit_card', label: 'Credit Card' },
{ value: 'bank_transfer', label: 'Bank Transfer' },
{ value: 'cash', label: 'Cash' }
];
// AUTH CHECK
onMount(async () => {
if (!$isAuthenticated) {
await goto('/login');
return;
}
isAdmin = $profile?.role === 'admin';
try {
loading = true;
// Load invoice details
await loadInvoice();
loading = false;
} catch (err) {
console.error('Error loading invoice details:', err);
error = 'Failed to load invoice details';
loading = false;
}
});
// LOAD INVOICE
async function loadInvoice() {
try {
invoice = await invoiceService.getById(invoiceId);
if (invoice) {
// Get customer details
if (typeof invoice.customer === 'object') {
customer = invoice.customer;
} else {
customer = await customerService.getById(invoice.customer as string);
}
// Extract accounts, projects, and revenues
if (invoice.accounts && Array.isArray(invoice.accounts)) {
accounts = invoice.accounts;
}
if (invoice.projects && Array.isArray(invoice.projects)) {
projects = invoice.projects;
}
if (invoice.revenues && Array.isArray(invoice.revenues)) {
revenues = invoice.revenues;
}
}
} catch (err) {
console.error('Error fetching invoice:', err);
error = 'Failed to load invoice';
}
}
// SAVE INVOICE
async function saveInvoice() {
if (!invoice) return;
try {
loading = true;
await invoiceService.update(invoiceId, invoice);
isEditing = false;
await loadInvoice(); // Reload to get fresh data
loading = false;
} catch (err) {
console.error('Error updating invoice:', err);
error = 'Failed to update invoice';
loading = false;
}
}
// MARK AS PAID
async function markAsPaid() {
if (!invoice) return;
try {
loading = true;
const today = new Date().toISOString().split('T')[0];
// Prepare payment details
const paymentData = {
status: 'paid',
date_paid: today,
payment_type: invoice.payment_type || 'check' // Default to check if not specified
};
await invoiceService.markAsPaid(invoiceId, paymentData);
await loadInvoice(); // Reload to get fresh data
loading = false;
} catch (err) {
console.error('Error marking invoice as paid:', err);
error = 'Failed to update invoice payment status';
loading = false;
}
}
// FORMAT DATE USING DATE-FNS
function formatDate(dateStr: string | undefined): string {
if (!dateStr) return 'N/A';
try {
const date = parseISO(dateStr); // Treat the string as an ISO 8601 UTC date
return format(date, 'MMMM d, yyyy'); // Format for display in local timezone
} catch (error) {
console.error('Error formatting date:', error);
return 'Invalid Date';
}
}
// FORMAT CURRENCY
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2
}).format(amount);
}
// GET TOTAL AMOUNT
function getInvoiceTotal(): number {
if (!invoice) return 0;
let total = 0;
// Add revenues if present
if (revenues && revenues.length > 0) {
total += revenues.reduce((sum, revenue) => sum + revenue.amount, 0);
}
// Add projects if present
if (projects && projects.length > 0) {
total += projects.reduce((sum, project) => sum + (project.amount || 0), 0);
}
return total;
}
</script>
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="/invoices" class="btn btn-outline-secondary me-2">
<i class="bi bi-arrow-left"></i> Back to Invoices
</a>
<h1 class="d-inline-block mb-0">Invoice Details</h1>
</div>
{#if isAdmin && !isEditing && invoice && invoice.status !== 'paid' && invoice.status !== 'cancelled'}
<div>
<button class="btn btn-primary me-2" onclick={() => isEditing = true}>
<i class="bi bi-pencil"></i> Edit Invoice
</button>
{#if invoice.status === 'sent'}
<button class="btn btn-success" onclick={markAsPaid}>
<i class="bi bi-check-circle"></i> Mark as Paid
</button>
{/if}
</div>
{/if}
</div>
{#if loading}
<div class="text-center p-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
{:else if error}
<div class="alert alert-danger" role="alert">
{error}
</div>
{:else if !invoice}
<div class="alert alert-warning" role="alert">
Invoice not found
</div>
{:else}
<div class="row">
<!-- Invoice Details -->
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">Invoice Information</h5>
<span class={`badge ${
invoice.status === 'paid' ? 'bg-success' :
invoice.status === 'overdue' ? 'bg-danger' :
invoice.status === 'sent' ? 'bg-primary' :
invoice.status === 'cancelled' ? 'bg-secondary' : 'bg-info'
} fs-6`}>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</span>
</div>
<div class="card-body">
{#if isEditing}
<!-- Edit Mode -->
<form onsubmit={saveInvoice}>
<div class="row mb-3">
<div class="col-md-6">
<label for="date" class="form-label">Invoice Date</label>
<input
type="date"
class="form-control"
id="date"
bind:value={invoice.date}
required
/>
</div>
<div class="col-md-6">
<label for="status" class="form-label">Status</label>
<select
class="form-select"
id="status"
bind:value={invoice.status}
required
>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
{#if invoice.status === 'paid' || isEditing}
<div class="row mb-3">
<div class="col-md-6">
<label for="date_paid" class="form-label">Date Paid</label>
<input
type="date"
class="form-control"
id="date_paid"
bind:value={invoice.date_paid}
disabled={invoice.status !== 'paid'}
/>
</div>
<div class="col-md-6">
<label for="payment_type" class="form-label">Payment Method</label>
<select
class="form-select"
id="payment_type"
bind:value={invoice.payment_type}
disabled={invoice.status !== 'paid'}
>
{#each paymentOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="d-flex justify-content-end mt-4">
<button
type="button"
class="btn btn-outline-secondary me-2"
onclick={() => isEditing = false}
>
Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Changes
</button>
</div>
</form>
{:else}
<!-- View Mode -->
<div class="row mb-4">
<div class="col-md-6">
<h6 class="text-muted">Invoice Date</h6>
<p class="fs-5">{formatDate(invoice.date)}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted">Invoice ID</h6>
<p class="fs-5">{invoice.id}</p>
</div>
</div>
{#if invoice.status === 'paid' && invoice.date_paid}
<div class="row mb-4">
<div class="col-md-6">
<h6 class="text-muted">Date Paid</h6>
<p class="fs-5">{formatDate(invoice.date_paid)}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted">Payment Method</h6>
<p class="fs-5">
{invoice.payment_type ?
invoice.payment_type.charAt(0).toUpperCase() + invoice.payment_type.slice(1).replace('_', ' ') :
'Not specified'}
</p>
</div>
</div>
{/if}
<div class="row mb-4">
<div class="col-12">
<h6 class="text-muted">Customer</h6>
<p class="fs-5">
<a href={customer ? `/customers/${customer.id}` : '#'}>
{customer ? customer.name : 'Unknown Customer'}
</a>
</p>
</div>
</div>
{#if customer}
<div class="row mb-4">
<div class="col-md-6">
<h6 class="text-muted">Billing Contact</h6>
<p>
{customer.billing_contact_first_name} {customer.billing_contact_last_name}<br>
<a href={`mailto:${customer.billing_email}`}>{customer.billing_email}</a>
</p>
</div>
<div class="col-md-6">
<h6 class="text-muted">Billing Address</h6>
<p>
{customer.billing_street_address}<br>
{customer.billing_city}, {customer.billing_state} {customer.billing_zip_code}
</p>
</div>
</div>
{/if}
{/if}
</div>
</div>
<!-- Items Section -->
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">Invoice Items</h5>
</div>
<div class="card-body p-0">
<!-- Accounts/Revenues Section -->
{#if revenues && revenues.length > 0}
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Account</th>
<th>Description</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{#each revenues as revenue (revenue.id)}
<tr>
<td>
{typeof revenue.account === 'object' ?
revenue.account.name :
accounts.find(a => a.id === revenue.account)?.name || 'Unknown Account'}
</td>
<td>Monthly Service ({formatDate(revenue.start_date)} - {revenue.end_date ? formatDate(revenue.end_date) : 'Ongoing'})</td>
<td>{formatCurrency(revenue.amount)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Projects Section -->
{#if projects && projects.length > 0}
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Project</th>
<th>Date</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{#each projects as project (project.id)}
<tr>
<td>
<a href={`/projects/${project.id}`}>
Project for {typeof project.customer === 'object' ?
project.customer.name :
customer?.name || 'Unknown Customer'}
</a>
</td>
<td>{formatDate(project.date)}</td>
<td>{formatCurrency(project.amount || 0)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if (!revenues || revenues.length === 0) && (!projects || projects.length === 0)}
<div class="p-4 text-center">
<p class="text-muted">No items attached to this invoice.</p>
</div>
{/if}
</div>
<div class="card-footer bg-light">
<div class="d-flex justify-content-between">
<h5>Total</h5>
<h5>{formatCurrency(getInvoiceTotal())}</h5>
</div>
</div>
</div>
</div>
<!-- Actions Sidebar -->
<div class="col-lg-4">
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button class="btn btn-outline-primary">
<i class="bi bi-printer"></i> Print Invoice
</button>
<button class="btn btn-outline-primary">
<i class="bi bi-download"></i> Download PDF
</button>
<button class="btn btn-outline-primary">
<i class="bi bi-envelope"></i> Email to Customer
</button>
{#if invoice.status === 'draft'}
<button class="btn btn-primary">
<i class="bi bi-send"></i> Send to Customer
</button>
{/if}
{#if invoice.status === 'sent'}
<button class="btn btn-success" onclick={markAsPaid}>
<i class="bi bi-check-circle"></i> Mark as Paid
</button>
<button class="btn btn-danger">
<i class="bi bi-exclamation-triangle"></i> Mark as Overdue
</button>
{/if}
</div>
</div>
</div>
<!-- Payment History -->
{#if invoice.status === 'paid' && invoice.date_paid}
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">Payment History</h5>
</div>
<div class="card-body">
<div class="d-flex align-items-center">
<div class="bg-success rounded-circle p-2 me-3">
<i class="bi bi-check-lg text-white"></i>
</div>
<div>
<h6 class="mb-0">Payment Received</h6>
<p class="text-muted mb-0">{formatDate(invoice.date_paid)}</p>
</div>
<div class="ms-auto">
<span class="fw-bold">{formatCurrency(getInvoiceTotal())}</span>
</div>
</div>
<div class="mt-3">
<p class="mb-1">Payment Method:</p>
<p class="fw-bold">
{invoice.payment_type ?
invoice.payment_type.charAt(0).toUpperCase() + invoice.payment_type.slice(1).replace('_', ' ') :
'Not specified'}
</p>
</div>
</div>
</div>
{/if}
<!-- Customer Info -->
{#if customer}
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">Customer Information</h5>
</div>
<div class="card-body">
<h6>{customer.name}</h6>
<p class="mb-2">
<strong>Primary Contact:</strong><br>
{customer.primary_contact_first_name} {customer.primary_contact_last_name}<br>
<a href={`tel:${customer.primary_contact_phone}`}>{customer.primary_contact_phone}</a><br>
<a href={`mailto:${customer.primary_contact_email}`}>{customer.primary_contact_email}</a>
</p>
<p class="mb-0">
<strong>Billing Contact:</strong><br>
{customer.billing_contact_first_name} {customer.billing_contact_last_name}<br>
<a href={`mailto:${customer.billing_email}`}>{customer.billing_email}</a>
</p>
<div class="mt-3">
<a href={`/customers/${customer.id}`} class="btn btn-outline-secondary btn-sm">
<i class="bi bi-person"></i> View Customer Profile
</a>
</div>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>