550 lines
19 KiB
Svelte
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> |