Add web frontend for invoice generation service

- Add Jinja2 templates and static files for web UI
- Create frontend routes for invoice management
- Implement home page with recent invoices list
- Add invoice creation form with dynamic items
- Create invoice search functionality
- Implement invoice details view with status update
- Add JavaScript for form validation and dynamic UI
- Update main.py to serve static files
- Update documentation
This commit is contained in:
Automated Action 2025-05-18 21:43:44 +00:00
parent 0a65bff5f3
commit 0b5aa51985
11 changed files with 1203 additions and 6 deletions

View File

@ -9,6 +9,7 @@ This is a FastAPI application for generating and managing invoices. It allows yo
- Retrieve invoice information by ID or invoice number
- Update invoice details and status
- Delete invoices
- Web-based user interface for invoice management
- Health check endpoint
## Technical Stack
@ -17,6 +18,7 @@ This is a FastAPI application for generating and managing invoices. It allows yo
- **Database**: SQLite with SQLAlchemy ORM
- **Migrations**: Alembic
- **Validation**: Pydantic
- **Frontend**: Jinja2 Templates, HTML, CSS, JavaScript
- **Linting**: Ruff
## Project Structure
@ -28,7 +30,8 @@ This is a FastAPI application for generating and managing invoices. It allows yo
│ ├── api # API routes
│ │ └── routes # API route modules
│ │ ├── __init__.py # Routes initialization
│ │ └── invoices.py # Invoice routes
│ │ ├── invoices.py # Invoice API routes
│ │ └── frontend.py # Frontend routes
│ ├── core # Core functionality
│ │ ├── config.py # Application settings
│ │ ├── database.py # Database connection setup
@ -36,9 +39,20 @@ This is a FastAPI application for generating and managing invoices. It allows yo
│ ├── models # SQLAlchemy models
│ │ ├── __init__.py # Models initialization
│ │ └── invoice.py # Invoice and InvoiceItem models
│ └── schemas # Pydantic schemas
│ ├── __init__.py # Schemas initialization
│ └── invoice.py # Invoice-related schemas
│ ├── schemas # Pydantic schemas
│ │ ├── __init__.py # Schemas initialization
│ │ └── invoice.py # Invoice-related schemas
│ ├── static # Static files
│ │ ├── css # CSS styles
│ │ │ └── styles.css # Main stylesheet
│ │ └── js # JavaScript files
│ │ └── main.js # Main JavaScript file
│ └── templates # Jinja2 templates
│ ├── base.html # Base template with layout
│ ├── home.html # Home page
│ ├── create_invoice.html # Invoice creation form
│ ├── search_invoice.html # Search for invoices
│ └── invoice_details.html # Invoice details page
├── main.py # Application entry point
├── migrations # Alembic migrations
│ ├── env.py # Alembic environment
@ -55,7 +69,7 @@ This is a FastAPI application for generating and managing invoices. It allows yo
- `GET /health`: Check if the service is running
### Invoice Management
### Invoice Management API
- `POST /api/v1/invoices`: Create a new invoice
- `GET /api/v1/invoices`: List all invoices (with pagination)
@ -65,6 +79,16 @@ This is a FastAPI application for generating and managing invoices. It allows yo
- `PATCH /api/v1/invoices/{invoice_id}/status`: Update invoice status
- `DELETE /api/v1/invoices/{invoice_id}`: Delete an invoice
### Frontend Routes
- `GET /`: Home page with list of recent invoices
- `GET /create`: Form to create a new invoice
- `POST /create`: Process invoice creation form
- `GET /search`: Form to search for invoices by number
- `POST /search`: Process invoice search
- `GET /invoice/{invoice_id}`: View invoice details
- `POST /invoice/{invoice_id}/status`: Update invoice status
## Setup and Installation
1. Clone the repository
@ -87,6 +111,16 @@ Once the application is running, you can access:
- Interactive API documentation at `/docs` (Swagger UI)
- Alternative API documentation at `/redoc` (ReDoc)
## Web Interface
The application comes with a full web interface that allows users to:
- View a list of recent invoices on the home page
- Create new invoices using a dynamic form
- Search for existing invoices by invoice number
- View detailed invoice information
- Update invoice status (PENDING, PAID, CANCELLED)
- Print invoices
## Invoice Number Format
Invoices are automatically assigned a unique invoice number with the format:

206
app/api/routes/frontend.py Normal file
View File

@ -0,0 +1,206 @@
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.invoice import Invoice, InvoiceItem
from app.schemas.invoice import InvoiceCreate, InvoiceItemCreate, InvoiceStatusUpdate
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse)
async def home(request: Request, db: Session = Depends(get_db)):
"""Render the home page with recent invoices."""
invoices = db.query(Invoice).order_by(Invoice.date_created.desc()).limit(10).all()
return templates.TemplateResponse(
"home.html", {"request": request, "invoices": invoices}
)
@router.get("/create", response_class=HTMLResponse)
async def create_invoice_page(request: Request):
"""Render the create invoice form."""
# Set default due date to 30 days from now
due_date = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
return templates.TemplateResponse(
"create_invoice.html", {"request": request, "due_date": due_date}
)
@router.post("/create", response_class=HTMLResponse)
async def create_invoice(
request: Request,
customer_name: str = Form(...),
customer_email: Optional[str] = Form(None),
customer_address: Optional[str] = Form(None),
due_date: datetime = Form(...),
notes: Optional[str] = Form(None),
db: Session = Depends(get_db),
):
"""Process the invoice creation form and create a new invoice."""
form_data = await request.form()
# Extract invoice items from form data
items = []
item_index = 0
while True:
description_key = f"items[{item_index}][description]"
quantity_key = f"items[{item_index}][quantity]"
unit_price_key = f"items[{item_index}][unit_price]"
if description_key not in form_data:
break
description = form_data.get(description_key)
quantity = form_data.get(quantity_key)
unit_price = form_data.get(unit_price_key)
if description and quantity and unit_price:
items.append(
InvoiceItemCreate(
description=description,
quantity=float(quantity),
unit_price=float(unit_price),
)
)
item_index += 1
# Create the invoice
if not items:
# If no valid items, return to form with error
return templates.TemplateResponse(
"create_invoice.html",
{
"request": request,
"error": "At least one invoice item is required",
"customer_name": customer_name,
"customer_email": customer_email,
"customer_address": customer_address,
"due_date": due_date.strftime("%Y-%m-%d"),
"notes": notes,
},
)
invoice_data = InvoiceCreate(
customer_name=customer_name,
customer_email=customer_email,
customer_address=customer_address,
due_date=due_date,
notes=notes,
items=items,
)
# Create new invoice in database
from app.core.utils import generate_invoice_number
db_invoice = Invoice(
invoice_number=generate_invoice_number(),
date_created=datetime.utcnow(),
due_date=invoice_data.due_date,
customer_name=invoice_data.customer_name,
customer_email=invoice_data.customer_email,
customer_address=invoice_data.customer_address,
notes=invoice_data.notes,
status="PENDING",
total_amount=0, # Will be calculated from items
)
db.add(db_invoice)
db.flush() # Flush to get the ID
# Create invoice items
total_amount = 0
for item_data in invoice_data.items:
item_amount = item_data.quantity * item_data.unit_price
total_amount += item_amount
db_item = InvoiceItem(
invoice_id=db_invoice.id,
description=item_data.description,
quantity=item_data.quantity,
unit_price=item_data.unit_price,
amount=item_amount,
)
db.add(db_item)
# Update total amount
db_invoice.total_amount = total_amount
# Commit the transaction
db.commit()
db.refresh(db_invoice)
# Redirect to invoice details page
return RedirectResponse(
url=f"/invoice/{db_invoice.id}", status_code=303
) # 303 See Other
@router.get("/search", response_class=HTMLResponse)
async def search_invoice_page(request: Request):
"""Render the search invoice form."""
return templates.TemplateResponse("search_invoice.html", {"request": request})
@router.post("/search", response_class=HTMLResponse)
async def search_invoice(
request: Request, invoice_number: str = Form(...), db: Session = Depends(get_db)
):
"""Process the search form and find an invoice by number."""
invoice = (
db.query(Invoice).filter(Invoice.invoice_number == invoice_number).first()
)
if not invoice:
return templates.TemplateResponse(
"search_invoice.html",
{
"request": request,
"error": f"Invoice with number {invoice_number} not found",
"invoice_number": invoice_number,
},
)
# Redirect to invoice details page
return RedirectResponse(url=f"/invoice/{invoice.id}", status_code=303)
@router.get("/invoice/{invoice_id}", response_class=HTMLResponse)
async def invoice_details(request: Request, invoice_id: int, db: Session = Depends(get_db)):
"""Render the invoice details page."""
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
return templates.TemplateResponse(
"invoice_details.html", {"request": request, "invoice": invoice}
)
@router.post("/invoice/{invoice_id}/status", response_class=HTMLResponse)
async def update_status(
request: Request,
invoice_id: int,
status: str = Form(...),
db: Session = Depends(get_db),
):
"""Update the status of an invoice."""
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
status_update = InvoiceStatusUpdate(status=status)
invoice.status = status_update.status
db.commit()
return RedirectResponse(
url=f"/invoice/{invoice.id}", status_code=303
)

302
app/static/css/styles.css Normal file
View File

@ -0,0 +1,302 @@
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f7fa;
}
.container {
width: 90%;
max-width: 1200px;
margin: 0 auto;
padding: 0 15px;
}
/* Header styles */
header {
background-color: #2c3e50;
color: white;
padding: 1rem 0;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
header h1 {
font-size: 1.8rem;
margin-bottom: 0.5rem;
}
nav ul {
display: flex;
list-style: none;
}
nav ul li {
margin-right: 1rem;
}
nav ul li a {
color: white;
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
nav ul li a:hover {
color: #3498db;
}
/* Main content styles */
main {
min-height: calc(100vh - 180px);
padding: 2rem 0;
}
h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
font-weight: 600;
}
/* Card styles */
.card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
/* Form styles */
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="date"],
textarea,
select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="number"]:focus,
input[type="date"]:focus,
textarea:focus,
select:focus {
border-color: #3498db;
outline: none;
}
/* Button styles */
.btn {
display: inline-block;
background-color: #3498db;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.btn-danger {
background-color: #e74c3c;
}
.btn-danger:hover {
background-color: #c0392b;
}
/* Table styles */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
}
table th,
table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
table th {
background-color: #f5f7fa;
font-weight: 600;
}
/* Alert styles */
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Footer styles */
footer {
background-color: #2c3e50;
color: white;
padding: 1rem 0;
text-align: center;
}
/* Responsive styles */
@media (max-width: 768px) {
nav ul {
flex-direction: column;
}
nav ul li {
margin-right: 0;
margin-bottom: 0.5rem;
}
}
/* Invoice item styles */
.invoice-items {
margin-bottom: 1.5rem;
}
.invoice-item {
display: flex;
flex-wrap: wrap;
gap: 1rem;
background-color: #f9f9f9;
padding: 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.invoice-item .form-group {
flex: 1;
min-width: 200px;
margin-bottom: 0.5rem;
}
.add-item-btn {
background-color: #2ecc71;
margin-right: 1rem;
}
.add-item-btn:hover {
background-color: #27ae60;
}
.remove-item-btn {
background-color: #e74c3c;
}
.remove-item-btn:hover {
background-color: #c0392b;
}
/* Search form */
.search-form {
display: flex;
margin-bottom: 1.5rem;
}
.search-form input {
flex: 1;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.search-form .btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
/* Invoice details */
.invoice-details {
margin-bottom: 2rem;
}
.invoice-details dl {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 0.5rem;
}
.invoice-details dt {
font-weight: 600;
color: #7f8c8d;
}
.invoice-details dd {
margin-left: 0;
}
.invoice-status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
text-transform: uppercase;
font-size: 0.875rem;
}
.status-pending {
background-color: #ffeeba;
color: #856404;
}
.status-paid {
background-color: #c3e6cb;
color: #155724;
}
.status-cancelled {
background-color: #f5c6cb;
color: #721c24;
}

150
app/static/js/main.js Normal file
View File

@ -0,0 +1,150 @@
// Main JavaScript file for the Invoice Generation Service
document.addEventListener('DOMContentLoaded', function() {
// Invoice items dynamic form functionality
const addItemButton = document.getElementById('add-item-btn');
if (addItemButton) {
addItemButton.addEventListener('click', addInvoiceItem);
}
// Add event listeners to any initial remove buttons
const removeButtons = document.querySelectorAll('.remove-item-btn');
removeButtons.forEach(button => {
button.addEventListener('click', removeInvoiceItem);
});
// Calculate amounts when quantity or unit price changes
const invoiceItems = document.querySelector('.invoice-items');
if (invoiceItems) {
invoiceItems.addEventListener('input', function(e) {
if (e.target.name && (e.target.name.includes('quantity') || e.target.name.includes('unit_price'))) {
updateItemAmount(e.target);
}
});
}
// Form validation
const invoiceForm = document.getElementById('invoice-form');
if (invoiceForm) {
invoiceForm.addEventListener('submit', validateInvoiceForm);
}
});
// Function to add a new invoice item row
function addInvoiceItem(e) {
e.preventDefault();
const invoiceItems = document.querySelector('.invoice-items');
const itemTemplate = document.querySelector('.invoice-item').cloneNode(true);
// Clear values in the cloned template
const inputs = itemTemplate.querySelectorAll('input');
inputs.forEach(input => {
input.value = '';
// Update the input name with a new index
const currentIndex = parseInt(input.name.match(/\d+/)[0]);
input.name = input.name.replace(`[${currentIndex}]`, `[${currentIndex + 1}]`);
});
// Add event listener to the remove button
const removeButton = itemTemplate.querySelector('.remove-item-btn');
if (removeButton) {
removeButton.addEventListener('click', removeInvoiceItem);
}
invoiceItems.appendChild(itemTemplate);
}
// Function to remove an invoice item row
function removeInvoiceItem(e) {
e.preventDefault();
const invoiceItems = document.querySelector('.invoice-items');
// Don't remove if it's the only item
if (invoiceItems.children.length > 1) {
e.target.closest('.invoice-item').remove();
} else {
// Clear values if it's the last item
const inputs = e.target.closest('.invoice-item').querySelectorAll('input');
inputs.forEach(input => {
input.value = '';
});
}
}
// Function to update the amount field based on quantity and unit price
function updateItemAmount(input) {
const itemRow = input.closest('.invoice-item');
const quantityInput = itemRow.querySelector('input[name*="quantity"]');
const unitPriceInput = itemRow.querySelector('input[name*="unit_price"]');
const amountDisplay = itemRow.querySelector('.amount-display');
if (quantityInput && unitPriceInput && amountDisplay) {
const quantity = parseFloat(quantityInput.value) || 0;
const unitPrice = parseFloat(unitPriceInput.value) || 0;
const amount = (quantity * unitPrice).toFixed(2);
amountDisplay.textContent = amount;
}
}
// Function to validate the invoice form before submission
function validateInvoiceForm(e) {
let valid = true;
// Reset any previous error messages
const errorMessages = document.querySelectorAll('.error-message');
errorMessages.forEach(message => message.remove());
// Validate customer name
const customerName = document.getElementById('customer_name');
if (!customerName.value.trim()) {
showError(customerName, 'Customer name is required');
valid = false;
}
// Validate due date
const dueDate = document.getElementById('due_date');
if (!dueDate.value) {
showError(dueDate, 'Due date is required');
valid = false;
}
// Validate invoice items
const itemRows = document.querySelectorAll('.invoice-item');
let hasValidItems = false;
itemRows.forEach(row => {
const description = row.querySelector('input[name*="description"]');
const quantity = row.querySelector('input[name*="quantity"]');
const unitPrice = row.querySelector('input[name*="unit_price"]');
if (description.value.trim() && quantity.value && unitPrice.value) {
hasValidItems = true;
}
});
if (!hasValidItems) {
const invoiceItems = document.querySelector('.invoice-items');
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = 'At least one complete invoice item is required';
errorDiv.style.color = 'red';
invoiceItems.parentNode.insertBefore(errorDiv, invoiceItems.nextSibling);
valid = false;
}
if (!valid) {
e.preventDefault();
}
}
// Helper function to show error messages
function showError(input, message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
errorDiv.style.color = 'red';
input.parentNode.appendChild(errorDiv);
}

37
app/templates/base.html Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Invoice Generation Service{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='/css/styles.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
<header>
<div class="container">
<h1>Invoice Generation Service</h1>
<nav>
<ul>
<li><a href="{{ url_for('home') }}">Home</a></li>
<li><a href="{{ url_for('create_invoice_page') }}">Create Invoice</a></li>
<li><a href="{{ url_for('search_invoice_page') }}">Find Invoice</a></li>
</ul>
</nav>
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer>
<div class="container">
<p>&copy; 2023 Invoice Generation Service</p>
</div>
</footer>
<script src="{{ url_for('static', path='/js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}Create Invoice - Invoice Generation Service{% endblock %}
{% block content %}
<div class="card">
<h2>Create New Invoice</h2>
{% if error %}
<div class="alert alert-error">
{{ error }}
</div>
{% endif %}
<form id="invoice-form" method="POST" action="{{ url_for('create_invoice') }}">
<div class="form-section">
<h3>Customer Information</h3>
<div class="form-group">
<label for="customer_name">Customer Name *</label>
<input type="text" id="customer_name" name="customer_name" required value="{{ customer_name or '' }}">
</div>
<div class="form-group">
<label for="customer_email">Customer Email</label>
<input type="email" id="customer_email" name="customer_email" value="{{ customer_email or '' }}">
</div>
<div class="form-group">
<label for="customer_address">Customer Address</label>
<textarea id="customer_address" name="customer_address" rows="3">{{ customer_address or '' }}</textarea>
</div>
</div>
<div class="form-section">
<h3>Invoice Details</h3>
<div class="form-group">
<label for="due_date">Due Date *</label>
<input type="date" id="due_date" name="due_date" required value="{{ due_date }}">
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="3">{{ notes or '' }}</textarea>
</div>
</div>
<div class="form-section">
<h3>Invoice Items</h3>
<p>Add at least one item to your invoice.</p>
<div class="invoice-items">
<div class="invoice-item">
<div class="form-group">
<label for="item-description-0">Description *</label>
<input type="text" id="item-description-0" name="items[0][description]" required>
</div>
<div class="form-group">
<label for="item-quantity-0">Quantity *</label>
<input type="number" id="item-quantity-0" name="items[0][quantity]" min="0.01" step="0.01" required>
</div>
<div class="form-group">
<label for="item-unit-price-0">Unit Price ($) *</label>
<input type="number" id="item-unit-price-0" name="items[0][unit_price]" min="0.01" step="0.01" required>
</div>
<div class="form-group">
<label>Amount ($)</label>
<div class="amount-display">0.00</div>
</div>
<button type="button" class="btn remove-item-btn">Remove</button>
</div>
</div>
<div class="item-buttons">
<button type="button" id="add-item-btn" class="btn add-item-btn">Add Item</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn">Generate Invoice</button>
<a href="{{ url_for('home') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}
{% block extra_css %}
<style>
.form-section {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.form-section h3 {
margin-bottom: 1rem;
color: #3498db;
}
.invoice-items {
margin-bottom: 1rem;
}
.item-buttons {
margin-bottom: 1rem;
}
.form-actions {
margin-top: 2rem;
display: flex;
gap: 1rem;
}
.amount-display {
padding: 0.75rem;
background-color: #f5f7fa;
border: 1px solid #ddd;
border-radius: 4px;
font-weight: 500;
}
</style>
{% endblock %}

96
app/templates/home.html Normal file
View File

@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Home - Invoice Generation Service{% endblock %}
{% block content %}
<div class="card">
<h2>Welcome to the Invoice Generation Service</h2>
<p>This application allows you to create, manage, and track invoices. Use the navigation menu above to access different features.</p>
<div class="features">
<div class="feature">
<h3>Create Invoices</h3>
<p>Generate new invoices with customer information, due dates, and line items.</p>
<a href="{{ url_for('create_invoice_page') }}" class="btn">Create Invoice</a>
</div>
<div class="feature">
<h3>Find Invoices</h3>
<p>Look up existing invoices by their unique invoice number.</p>
<a href="{{ url_for('search_invoice_page') }}" class="btn">Find Invoice</a>
</div>
</div>
</div>
{% if invoices %}
<div class="card">
<h2>Recent Invoices</h2>
<table>
<thead>
<tr>
<th>Invoice Number</th>
<th>Customer</th>
<th>Date Created</th>
<th>Due Date</th>
<th>Total Amount</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td>{{ invoice.invoice_number }}</td>
<td>{{ invoice.customer_name }}</td>
<td>{{ invoice.date_created.strftime('%Y-%m-%d') }}</td>
<td>{{ invoice.due_date.strftime('%Y-%m-%d') }}</td>
<td>${{ "%.2f"|format(invoice.total_amount) }}</td>
<td>
<span class="invoice-status status-{{ invoice.status.lower() }}">
{{ invoice.status }}
</span>
</td>
<td>
<a href="{{ url_for('invoice_details', invoice_id=invoice.id) }}" class="btn">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block extra_css %}
<style>
.features {
display: flex;
gap: 2rem;
margin-top: 2rem;
}
.feature {
flex: 1;
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
text-align: center;
}
.feature h3 {
margin-bottom: 0.5rem;
color: #2c3e50;
}
.feature p {
margin-bottom: 1.5rem;
color: #7f8c8d;
}
@media (max-width: 768px) {
.features {
flex-direction: column;
}
}
</style>
{% endblock %}

View File

@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}Invoice {{ invoice.invoice_number }} - Invoice Generation Service{% endblock %}
{% block content %}
<div class="card">
<div class="invoice-header">
<h2>Invoice #{{ invoice.invoice_number }}</h2>
<span class="invoice-status status-{{ invoice.status.lower() }}">
{{ invoice.status }}
</span>
</div>
<div class="invoice-actions">
<button id="print-invoice" class="btn">Print Invoice</button>
<form method="POST" action="{{ url_for('update_status', invoice_id=invoice.id) }}" class="status-form">
<select name="status" id="invoice-status">
<option value="PENDING" {% if invoice.status == 'PENDING' %}selected{% endif %}>PENDING</option>
<option value="PAID" {% if invoice.status == 'PAID' %}selected{% endif %}>PAID</option>
<option value="CANCELLED" {% if invoice.status == 'CANCELLED' %}selected{% endif %}>CANCELLED</option>
</select>
<button type="submit" class="btn">Update Status</button>
</form>
</div>
<div class="invoice-content">
<div class="invoice-section">
<h3>Invoice Details</h3>
<dl>
<dt>Invoice Number</dt>
<dd>{{ invoice.invoice_number }}</dd>
<dt>Date Created</dt>
<dd>{{ invoice.date_created.strftime('%Y-%m-%d') }}</dd>
<dt>Due Date</dt>
<dd>{{ invoice.due_date.strftime('%Y-%m-%d') }}</dd>
<dt>Status</dt>
<dd>
<span class="invoice-status status-{{ invoice.status.lower() }}">
{{ invoice.status }}
</span>
</dd>
</dl>
</div>
<div class="invoice-section">
<h3>Customer Information</h3>
<dl>
<dt>Name</dt>
<dd>{{ invoice.customer_name }}</dd>
{% if invoice.customer_email %}
<dt>Email</dt>
<dd>{{ invoice.customer_email }}</dd>
{% endif %}
{% if invoice.customer_address %}
<dt>Address</dt>
<dd>{{ invoice.customer_address }}</dd>
{% endif %}
</dl>
</div>
{% if invoice.notes %}
<div class="invoice-section">
<h3>Notes</h3>
<p>{{ invoice.notes }}</p>
</div>
{% endif %}
<div class="invoice-section">
<h3>Invoice Items</h3>
<table class="items-table">
<thead>
<tr>
<th>Description</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{% for item in invoice.items %}
<tr>
<td>{{ item.description }}</td>
<td>{{ item.quantity }}</td>
<td>${{ "%.2f"|format(item.unit_price) }}</td>
<td>${{ "%.2f"|format(item.amount) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="total-label">Total Amount</td>
<td class="total-amount">${{ "%.2f"|format(invoice.total_amount) }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.invoice-actions {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.status-form {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-form select {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #ddd;
}
.invoice-section {
margin-bottom: 2rem;
}
.invoice-section h3 {
color: #3498db;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #eee;
}
.invoice-section dl {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 0.75rem;
}
.invoice-section dt {
font-weight: 600;
color: #7f8c8d;
}
.items-table {
width: 100%;
border-collapse: collapse;
}
.items-table th,
.items-table td {
padding: 0.75rem;
border-bottom: 1px solid #ddd;
}
.items-table th {
background-color: #f5f7fa;
}
.total-label {
text-align: right;
font-weight: 600;
}
.total-amount {
font-weight: 700;
font-size: 1.1rem;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.getElementById('print-invoice').addEventListener('click', function() {
window.print();
});
</script>
{% endblock %}

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Find Invoice - Invoice Generation Service{% endblock %}
{% block content %}
<div class="card">
<h2>Find Invoice</h2>
<p>Enter the invoice number to search for an existing invoice.</p>
{% if error %}
<div class="alert alert-error">
{{ error }}
</div>
{% endif %}
<form method="POST" action="{{ url_for('search_invoice') }}" class="search-form-container">
<div class="search-form">
<input type="text" id="invoice_number" name="invoice_number" placeholder="Enter invoice number..." value="{{ invoice_number or '' }}" required>
<button type="submit" class="btn">Search</button>
</div>
<p class="help-text">Example format: INV-YYYYMM-XXXXXX</p>
</form>
</div>
{% endblock %}
{% block extra_css %}
<style>
.search-form-container {
max-width: 600px;
margin: 2rem auto;
}
.search-form {
display: flex;
margin-bottom: 0.5rem;
}
.help-text {
font-size: 0.9rem;
color: #7f8c8d;
margin-top: 0.5rem;
}
</style>
{% endblock %}

View File

@ -1,7 +1,9 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.routes import api_router
from app.api.routes.frontend import router as frontend_router
from app.core.config import settings
app = FastAPI(
@ -21,9 +23,15 @@ if settings.BACKEND_CORS_ORIGINS:
allow_headers=["*"],
)
# Mount static files directory
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
# Include frontend router
app.include_router(frontend_router)
@app.get("/health", status_code=200)
def health():

View File

@ -7,4 +7,6 @@ pydantic-settings>=2.0.0
python-dateutil>=2.8.2
typing-extensions>=4.7.1
ruff>=0.0.292
python-dotenv>=1.0.0
python-dotenv>=1.0.0
jinja2>=3.1.2
aiofiles>=23.1.0