diff --git a/README.md b/README.md index 1050c31..681ffd4 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/api/routes/frontend.py b/app/api/routes/frontend.py new file mode 100644 index 0000000..4d47754 --- /dev/null +++ b/app/api/routes/frontend.py @@ -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 + ) \ No newline at end of file diff --git a/app/static/css/styles.css b/app/static/css/styles.css new file mode 100644 index 0000000..62ad9eb --- /dev/null +++ b/app/static/css/styles.css @@ -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; +} \ No newline at end of file diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..a3aaba7 --- /dev/null +++ b/app/static/js/main.js @@ -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); +} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..5995a93 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,37 @@ + + + + + + {% block title %}Invoice Generation Service{% endblock %} + + {% block extra_css %}{% endblock %} + + +
+
+

Invoice Generation Service

+ +
+
+ +
+ {% block content %}{% endblock %} +
+ + + + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/create_invoice.html b/app/templates/create_invoice.html new file mode 100644 index 0000000..cb472e0 --- /dev/null +++ b/app/templates/create_invoice.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block title %}Create Invoice - Invoice Generation Service{% endblock %} + +{% block content %} +
+

Create New Invoice

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+

Customer Information

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

Invoice Details

+ +
+ + +
+ +
+ + +
+
+ +
+

Invoice Items

+

Add at least one item to your invoice.

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
0.00
+
+ + +
+
+ +
+ +
+
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..089a811 --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} + +{% block title %}Home - Invoice Generation Service{% endblock %} + +{% block content %} +
+

Welcome to the Invoice Generation Service

+

This application allows you to create, manage, and track invoices. Use the navigation menu above to access different features.

+ +
+
+

Create Invoices

+

Generate new invoices with customer information, due dates, and line items.

+ Create Invoice +
+ +
+

Find Invoices

+

Look up existing invoices by their unique invoice number.

+ Find Invoice +
+
+
+ +{% if invoices %} +
+

Recent Invoices

+ + + + + + + + + + + + + + {% for invoice in invoices %} + + + + + + + + + + {% endfor %} + +
Invoice NumberCustomerDate CreatedDue DateTotal AmountStatusActions
{{ invoice.invoice_number }}{{ invoice.customer_name }}{{ invoice.date_created.strftime('%Y-%m-%d') }}{{ invoice.due_date.strftime('%Y-%m-%d') }}${{ "%.2f"|format(invoice.total_amount) }} + + {{ invoice.status }} + + + View +
+
+{% endif %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/invoice_details.html b/app/templates/invoice_details.html new file mode 100644 index 0000000..2863b8a --- /dev/null +++ b/app/templates/invoice_details.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} + +{% block title %}Invoice {{ invoice.invoice_number }} - Invoice Generation Service{% endblock %} + +{% block content %} +
+
+

Invoice #{{ invoice.invoice_number }}

+ + {{ invoice.status }} + +
+ +
+ + +
+ + +
+
+ +
+
+

Invoice Details

+
+
Invoice Number
+
{{ invoice.invoice_number }}
+ +
Date Created
+
{{ invoice.date_created.strftime('%Y-%m-%d') }}
+ +
Due Date
+
{{ invoice.due_date.strftime('%Y-%m-%d') }}
+ +
Status
+
+ + {{ invoice.status }} + +
+
+
+ +
+

Customer Information

+
+
Name
+
{{ invoice.customer_name }}
+ + {% if invoice.customer_email %} +
Email
+
{{ invoice.customer_email }}
+ {% endif %} + + {% if invoice.customer_address %} +
Address
+
{{ invoice.customer_address }}
+ {% endif %} +
+
+ + {% if invoice.notes %} +
+

Notes

+

{{ invoice.notes }}

+
+ {% endif %} + +
+

Invoice Items

+ + + + + + + + + + + {% for item in invoice.items %} + + + + + + + {% endfor %} + + + + + + + +
DescriptionQuantityUnit PriceAmount
{{ item.description }}{{ item.quantity }}${{ "%.2f"|format(item.unit_price) }}${{ "%.2f"|format(item.amount) }}
Total Amount${{ "%.2f"|format(invoice.total_amount) }}
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/search_invoice.html b/app/templates/search_invoice.html new file mode 100644 index 0000000..4ea3395 --- /dev/null +++ b/app/templates/search_invoice.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}Find Invoice - Invoice Generation Service{% endblock %} + +{% block content %} +
+

Find Invoice

+

Enter the invoice number to search for an existing invoice.

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
+

Example format: INV-YYYYMM-XXXXXX

+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} \ No newline at end of file diff --git a/main.py b/main.py index e889283..f7382f2 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/requirements.txt b/requirements.txt index dc34147..ad7870d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +python-dotenv>=1.0.0 +jinja2>=3.1.2 +aiofiles>=23.1.0 \ No newline at end of file