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:
parent
0a65bff5f3
commit
0b5aa51985
44
README.md
44
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:
|
||||
|
206
app/api/routes/frontend.py
Normal file
206
app/api/routes/frontend.py
Normal 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
302
app/static/css/styles.css
Normal 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
150
app/static/js/main.js
Normal 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
37
app/templates/base.html
Normal 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>© 2023 Invoice Generation Service</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', path='/js/main.js') }}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
127
app/templates/create_invoice.html
Normal file
127
app/templates/create_invoice.html
Normal 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
96
app/templates/home.html
Normal 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 %}
|
191
app/templates/invoice_details.html
Normal file
191
app/templates/invoice_details.html
Normal 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 %}
|
44
app/templates/search_invoice.html
Normal file
44
app/templates/search_invoice.html
Normal 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 %}
|
8
main.py
8
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():
|
||||
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user