add fixture-invoicing example, sample-room wrap, kind cluster support
- examples/fixture-invoicing/: FastAPI + Vue + Postgres demo (4-entity invoice fixture)
- cfg/sample/: wraps the fixture (managed.repos points at examples/)
- ctrl/kind-{up,down,status}.sh + per-room k8s render in soleprint/ctrl/k8s/
- build.py: relative repo paths, resilient rmtree, optional k8s render hook
- cfg/.gitignore: stop ignoring sample/ and standalone/ template rooms
Manifests render cleanly but kind cluster has not been run end-to-end yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
examples/.gitignore
vendored
Normal file
6
examples/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
46
examples/fixture-invoicing/README.md
Normal file
46
examples/fixture-invoicing/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Fixture Invoicing
|
||||
|
||||
> **⚠ THIS IS A FIXTURE, NOT A PRODUCT.**
|
||||
> A deliberately minimal invoicing app used as a test surface for the
|
||||
> [Soleprint](../../README.md) framework. Seed data is obviously placeholder
|
||||
> ("Acme Widget Co.", "Test Customer 001"). Do not mistake this for real
|
||||
> invoicing software.
|
||||
|
||||
## What's here
|
||||
|
||||
- `backend/` — FastAPI + SQLAlchemy against Postgres
|
||||
- `frontend/` — Vue 3 + Vite, three pages (customers, invoices, invoice detail)
|
||||
- `docker-compose.yml` — backend + frontend + postgres as three containers
|
||||
|
||||
## Entities
|
||||
|
||||
```
|
||||
Customer ──< Invoice ──< LineItem
|
||||
└──────< Payment
|
||||
```
|
||||
|
||||
- `Customer` — name, email
|
||||
- `Invoice` — number, customer_id (FK), issued_at, due_at, status
|
||||
- `LineItem` — invoice_id (FK), description, quantity, unit_price
|
||||
- `Payment` — invoice_id (FK), amount, method, paid_at
|
||||
|
||||
## Run it standalone
|
||||
|
||||
```bash
|
||||
cd examples/fixture-invoicing
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- Backend: http://localhost:8100
|
||||
- Frontend: http://localhost:3100
|
||||
- Postgres: localhost:5532 (user `postgres`, password `fixture`, db `fixture`)
|
||||
|
||||
## Run it wrapped by soleprint
|
||||
|
||||
The `cfg/sample/` room wraps this fixture. See the main soleprint docs.
|
||||
|
||||
```bash
|
||||
cd ../.. # back to repo root
|
||||
python build.py --cfg sample
|
||||
cd gen/sample && ./ctrl/start.sh
|
||||
```
|
||||
15
examples/fixture-invoicing/backend/Dockerfile
Normal file
15
examples/fixture-invoicing/backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
0
examples/fixture-invoicing/backend/api/__init__.py
Normal file
0
examples/fixture-invoicing/backend/api/__init__.py
Normal file
53
examples/fixture-invoicing/backend/api/customers.py
Normal file
53
examples/fixture-invoicing/backend/api/customers.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db import get_session
|
||||
from models import Customer
|
||||
from schemas import CustomerIn, CustomerOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[CustomerOut])
|
||||
def list_customers(session: Session = Depends(get_session)):
|
||||
return session.query(Customer).order_by(Customer.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=CustomerOut, status_code=201)
|
||||
def create_customer(payload: CustomerIn, session: Session = Depends(get_session)):
|
||||
customer = Customer(**payload.model_dump())
|
||||
session.add(customer)
|
||||
session.commit()
|
||||
session.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerOut)
|
||||
def get_customer(customer_id: int, session: Session = Depends(get_session)):
|
||||
customer = session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(404, "customer not found")
|
||||
return customer
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerOut)
|
||||
def update_customer(
|
||||
customer_id: int, payload: CustomerIn, session: Session = Depends(get_session)
|
||||
):
|
||||
customer = session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(404, "customer not found")
|
||||
for k, v in payload.model_dump().items():
|
||||
setattr(customer, k, v)
|
||||
session.commit()
|
||||
session.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", status_code=204)
|
||||
def delete_customer(customer_id: int, session: Session = Depends(get_session)):
|
||||
customer = session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(404, "customer not found")
|
||||
session.delete(customer)
|
||||
session.commit()
|
||||
71
examples/fixture-invoicing/backend/api/invoices.py
Normal file
71
examples/fixture-invoicing/backend/api/invoices.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from db import get_session
|
||||
from models import Invoice
|
||||
from schemas import InvoiceDetail, InvoiceIn, InvoiceOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[InvoiceOut])
|
||||
def list_invoices(
|
||||
status: str | None = None,
|
||||
customer_id: int | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
query = session.query(Invoice).order_by(Invoice.issued_at.desc())
|
||||
if status:
|
||||
query = query.filter(Invoice.status == status)
|
||||
if customer_id:
|
||||
query = query.filter(Invoice.customer_id == customer_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("", response_model=InvoiceOut, status_code=201)
|
||||
def create_invoice(payload: InvoiceIn, session: Session = Depends(get_session)):
|
||||
invoice = Invoice(**payload.model_dump())
|
||||
session.add(invoice)
|
||||
session.commit()
|
||||
session.refresh(invoice)
|
||||
return invoice
|
||||
|
||||
|
||||
@router.get("/{invoice_id}", response_model=InvoiceDetail)
|
||||
def get_invoice(invoice_id: int, session: Session = Depends(get_session)):
|
||||
invoice = (
|
||||
session.query(Invoice)
|
||||
.options(
|
||||
joinedload(Invoice.customer),
|
||||
joinedload(Invoice.line_items),
|
||||
joinedload(Invoice.payments),
|
||||
)
|
||||
.filter(Invoice.id == invoice_id)
|
||||
.first()
|
||||
)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
return invoice
|
||||
|
||||
|
||||
@router.put("/{invoice_id}", response_model=InvoiceOut)
|
||||
def update_invoice(
|
||||
invoice_id: int, payload: InvoiceIn, session: Session = Depends(get_session)
|
||||
):
|
||||
invoice = session.get(Invoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
for k, v in payload.model_dump().items():
|
||||
setattr(invoice, k, v)
|
||||
session.commit()
|
||||
session.refresh(invoice)
|
||||
return invoice
|
||||
|
||||
|
||||
@router.delete("/{invoice_id}", status_code=204)
|
||||
def delete_invoice(invoice_id: int, session: Session = Depends(get_session)):
|
||||
invoice = session.get(Invoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
session.delete(invoice)
|
||||
session.commit()
|
||||
40
examples/fixture-invoicing/backend/api/line_items.py
Normal file
40
examples/fixture-invoicing/backend/api/line_items.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db import get_session
|
||||
from models import Invoice, LineItem
|
||||
from schemas import LineItemIn, LineItemOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[LineItemOut])
|
||||
def list_line_items(
|
||||
invoice_id: int | None = None, session: Session = Depends(get_session)
|
||||
):
|
||||
query = session.query(LineItem).order_by(LineItem.id)
|
||||
if invoice_id:
|
||||
query = query.filter(LineItem.invoice_id == invoice_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("/invoices/{invoice_id}", response_model=LineItemOut, status_code=201)
|
||||
def add_line_item(
|
||||
invoice_id: int, payload: LineItemIn, session: Session = Depends(get_session)
|
||||
):
|
||||
if not session.get(Invoice, invoice_id):
|
||||
raise HTTPException(404, "invoice not found")
|
||||
item = LineItem(invoice_id=invoice_id, **payload.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{line_item_id}", status_code=204)
|
||||
def delete_line_item(line_item_id: int, session: Session = Depends(get_session)):
|
||||
item = session.get(LineItem, line_item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "line item not found")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
49
examples/fixture-invoicing/backend/api/payments.py
Normal file
49
examples/fixture-invoicing/backend/api/payments.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db import get_session
|
||||
from models import Invoice, Payment
|
||||
from schemas import PaymentIn, PaymentOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[PaymentOut])
|
||||
def list_payments(
|
||||
invoice_id: int | None = None, session: Session = Depends(get_session)
|
||||
):
|
||||
query = session.query(Payment).order_by(Payment.paid_at.desc())
|
||||
if invoice_id:
|
||||
query = query.filter(Payment.invoice_id == invoice_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("/invoices/{invoice_id}", response_model=PaymentOut, status_code=201)
|
||||
def record_payment(
|
||||
invoice_id: int, payload: PaymentIn, session: Session = Depends(get_session)
|
||||
):
|
||||
invoice = session.get(Invoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
payment = Payment(invoice_id=invoice_id, **payload.model_dump())
|
||||
session.add(payment)
|
||||
|
||||
total_paid = sum(
|
||||
(p.amount for p in invoice.payments), start=payment.amount
|
||||
)
|
||||
total_owed = sum((li.unit_price * li.quantity for li in invoice.line_items), start=0)
|
||||
if total_owed > 0 and total_paid >= total_owed:
|
||||
invoice.status = "paid"
|
||||
|
||||
session.commit()
|
||||
session.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
@router.delete("/{payment_id}", status_code=204)
|
||||
def delete_payment(payment_id: int, session: Session = Depends(get_session)):
|
||||
payment = session.get(Payment, payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(404, "payment not found")
|
||||
session.delete(payment)
|
||||
session.commit()
|
||||
29
examples/fixture-invoicing/backend/db.py
Normal file
29
examples/fixture-invoicing/backend/db.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
|
||||
def _db_url() -> str:
|
||||
host = os.getenv("DB_HOST", "localhost")
|
||||
port = os.getenv("DB_PORT", "5432")
|
||||
name = os.getenv("DB_NAME", "fixture")
|
||||
user = os.getenv("DB_USER", "postgres")
|
||||
password = os.getenv("DB_PASSWORD", "fixture")
|
||||
return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}"
|
||||
|
||||
|
||||
engine = create_engine(_db_url(), echo=False, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_session():
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
52
examples/fixture-invoicing/backend/main.py
Normal file
52
examples/fixture-invoicing/backend/main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api import customers, invoices, line_items, payments
|
||||
from db import Base, engine
|
||||
from seed import seed_if_empty
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("fixture")
|
||||
|
||||
app = FastAPI(
|
||||
title="Fixture Invoicing",
|
||||
description="SOLEPRINT FIXTURE APP — not a real invoicing product",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
if os.getenv("SEED_ON_START", "false").lower() == "true":
|
||||
seed_if_empty()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"app": "fixture-invoicing",
|
||||
"warning": "THIS IS A SOLEPRINT FIXTURE — NOT A REAL PRODUCT",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(customers.router, prefix="/api/customers", tags=["customers"])
|
||||
app.include_router(invoices.router, prefix="/api/invoices", tags=["invoices"])
|
||||
app.include_router(line_items.router, prefix="/api/line-items", tags=["line-items"])
|
||||
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
||||
63
examples/fixture-invoicing/backend/models.py
Normal file
63
examples/fixture-invoicing/backend/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from db import Base
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customer"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
invoices: Mapped[list["Invoice"]] = relationship(
|
||||
back_populates="customer", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Invoice(Base):
|
||||
__tablename__ = "invoice"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
number: Mapped[str] = mapped_column(String(40), nullable=False, unique=True)
|
||||
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"), nullable=False)
|
||||
issued_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
|
||||
customer: Mapped[Customer] = relationship(back_populates="invoices")
|
||||
line_items: Mapped[list["LineItem"]] = relationship(
|
||||
back_populates="invoice", cascade="all, delete-orphan"
|
||||
)
|
||||
payments: Mapped[list["Payment"]] = relationship(
|
||||
back_populates="invoice", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class LineItem(Base):
|
||||
__tablename__ = "line_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, default=1)
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0)
|
||||
|
||||
invoice: Mapped[Invoice] = relationship(back_populates="line_items")
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payment"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
method: Mapped[str] = mapped_column(String(20), default="cash")
|
||||
paid_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
invoice: Mapped[Invoice] = relationship(back_populates="payments")
|
||||
5
examples/fixture-invoicing/backend/requirements.txt
Normal file
5
examples/fixture-invoicing/backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.9.2
|
||||
58
examples/fixture-invoicing/backend/schemas.py
Normal file
58
examples/fixture-invoicing/backend/schemas.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CustomerIn(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
class CustomerOut(CustomerIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LineItemIn(BaseModel):
|
||||
description: str
|
||||
quantity: int = 1
|
||||
unit_price: Decimal = Decimal("0")
|
||||
|
||||
|
||||
class LineItemOut(LineItemIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
invoice_id: int
|
||||
|
||||
|
||||
class PaymentIn(BaseModel):
|
||||
amount: Decimal
|
||||
method: str = "cash"
|
||||
|
||||
|
||||
class PaymentOut(PaymentIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
invoice_id: int
|
||||
paid_at: datetime
|
||||
|
||||
|
||||
class InvoiceIn(BaseModel):
|
||||
number: str
|
||||
customer_id: int
|
||||
due_at: datetime | None = None
|
||||
status: str = "draft"
|
||||
|
||||
|
||||
class InvoiceOut(InvoiceIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
issued_at: datetime
|
||||
|
||||
|
||||
class InvoiceDetail(InvoiceOut):
|
||||
line_items: list[LineItemOut] = []
|
||||
payments: list[PaymentOut] = []
|
||||
customer: CustomerOut
|
||||
64
examples/fixture-invoicing/backend/seed.py
Normal file
64
examples/fixture-invoicing/backend/seed.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Seed obviously-fake demo data so the fixture is non-empty on first boot."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from db import SessionLocal
|
||||
from models import Customer, Invoice, LineItem, Payment
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_if_empty() -> None:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
if session.query(Customer).count() > 0:
|
||||
return
|
||||
|
||||
log.info("Seeding fixture data…")
|
||||
acme = Customer(name="Acme Widget Co.", email="billing@acme.example")
|
||||
test = Customer(name="Test Customer 001", email="test@example.invalid")
|
||||
session.add_all([acme, test])
|
||||
session.flush()
|
||||
|
||||
inv1 = Invoice(
|
||||
number="DEMO-2026-001",
|
||||
customer_id=acme.id,
|
||||
issued_at=datetime(2026, 1, 15),
|
||||
due_at=datetime(2026, 2, 15),
|
||||
status="sent",
|
||||
)
|
||||
inv2 = Invoice(
|
||||
number="DEMO-2026-002",
|
||||
customer_id=acme.id,
|
||||
issued_at=datetime(2026, 2, 1),
|
||||
due_at=datetime(2026, 3, 1),
|
||||
status="paid",
|
||||
)
|
||||
inv3 = Invoice(
|
||||
number="DEMO-2026-003",
|
||||
customer_id=test.id,
|
||||
issued_at=datetime(2026, 3, 10),
|
||||
due_at=datetime(2026, 4, 10),
|
||||
status="draft",
|
||||
)
|
||||
session.add_all([inv1, inv2, inv3])
|
||||
session.flush()
|
||||
|
||||
session.add_all([
|
||||
LineItem(invoice_id=inv1.id, description="Widget type A", quantity=10, unit_price=Decimal("19.99")),
|
||||
LineItem(invoice_id=inv1.id, description="Widget type B", quantity=5, unit_price=Decimal("49.00")),
|
||||
LineItem(invoice_id=inv2.id, description="Consulting (hours)", quantity=8, unit_price=Decimal("120.00")),
|
||||
LineItem(invoice_id=inv3.id, description="Placeholder line item", quantity=1, unit_price=Decimal("0")),
|
||||
])
|
||||
|
||||
session.add_all([
|
||||
Payment(invoice_id=inv2.id, amount=Decimal("960.00"), method="transfer", paid_at=datetime(2026, 2, 20)),
|
||||
Payment(invoice_id=inv1.id, amount=Decimal("100.00"), method="card", paid_at=datetime(2026, 2, 1)),
|
||||
])
|
||||
|
||||
session.commit()
|
||||
log.info("Seed complete.")
|
||||
finally:
|
||||
session.close()
|
||||
54
examples/fixture-invoicing/docker-compose.yml
Normal file
54
examples/fixture-invoicing/docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
name: fixture_invoicing
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: fixture_db
|
||||
environment:
|
||||
POSTGRES_DB: fixture
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: fixture
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5532:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d fixture"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: fixture_backend
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_NAME: fixture
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: fixture
|
||||
SEED_ON_START: "true"
|
||||
ports:
|
||||
- "8100:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
container_name: fixture_frontend
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:8100
|
||||
ports:
|
||||
- "3100:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/index.html:/app/index.html
|
||||
- ./frontend/vite.config.js:/app/vite.config.js
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
11
examples/fixture-invoicing/frontend/Dockerfile
Normal file
11
examples/fixture-invoicing/frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
12
examples/fixture-invoicing/frontend/index.html
Normal file
12
examples/fixture-invoicing/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fixture Invoicing — Soleprint Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
examples/fixture-invoicing/frontend/package.json
Normal file
19
examples/fixture-invoicing/frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "fixture-invoicing-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
17
examples/fixture-invoicing/frontend/src/App.vue
Normal file
17
examples/fixture-invoicing/frontend/src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="fixture-banner">
|
||||
⚠ FIXTURE APP — SOLEPRINT DEMO — NOT A REAL PRODUCT ⚠
|
||||
</div>
|
||||
|
||||
<header class="app-nav">
|
||||
<h1>Fixture Invoicing</h1>
|
||||
<nav>
|
||||
<router-link to="/customers">Customers</router-link>
|
||||
<router-link to="/invoices">Invoices</router-link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</template>
|
||||
42
examples/fixture-invoicing/frontend/src/api.js
Normal file
42
examples/fixture-invoicing/frontend/src/api.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const apiBase = import.meta.env.VITE_API_URL || "";
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const resp = await fetch(`${apiBase}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||||
if (resp.status === 204) return null;
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listCustomers: () => request("/api/customers"),
|
||||
createCustomer: (data) =>
|
||||
request("/api/customers", { method: "POST", body: JSON.stringify(data) }),
|
||||
deleteCustomer: (id) => request(`/api/customers/${id}`, { method: "DELETE" }),
|
||||
|
||||
listInvoices: (params = {}) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return request(`/api/invoices${qs ? "?" + qs : ""}`);
|
||||
},
|
||||
getInvoice: (id) => request(`/api/invoices/${id}`),
|
||||
createInvoice: (data) =>
|
||||
request("/api/invoices", { method: "POST", body: JSON.stringify(data) }),
|
||||
deleteInvoice: (id) => request(`/api/invoices/${id}`, { method: "DELETE" }),
|
||||
|
||||
addLineItem: (invoiceId, data) =>
|
||||
request(`/api/line-items/invoices/${invoiceId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteLineItem: (id) =>
|
||||
request(`/api/line-items/${id}`, { method: "DELETE" }),
|
||||
|
||||
recordPayment: (invoiceId, data) =>
|
||||
request(`/api/payments/invoices/${invoiceId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deletePayment: (id) => request(`/api/payments/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
6
examples/fixture-invoicing/frontend/src/main.js
Normal file
6
examples/fixture-invoicing/frontend/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router.js";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
15
examples/fixture-invoicing/frontend/src/router.js
Normal file
15
examples/fixture-invoicing/frontend/src/router.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import Customers from "./views/Customers.vue";
|
||||
import Invoices from "./views/Invoices.vue";
|
||||
import InvoiceDetail from "./views/InvoiceDetail.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", redirect: "/invoices" },
|
||||
{ path: "/customers", component: Customers },
|
||||
{ path: "/invoices", component: Invoices },
|
||||
{ path: "/invoices/:id", component: InvoiceDetail, props: true },
|
||||
],
|
||||
});
|
||||
119
examples/fixture-invoicing/frontend/src/style.css
Normal file
119
examples/fixture-invoicing/frontend/src/style.css
Normal file
@@ -0,0 +1,119 @@
|
||||
:root {
|
||||
--bg: #0d1b2a;
|
||||
--panel: #152438;
|
||||
--panel-2: #1b2e45;
|
||||
--text: #e5e5e5;
|
||||
--muted: #8ca0b4;
|
||||
--accent: #6fc3df;
|
||||
--warn: #ffb454;
|
||||
--danger: #ff6b6b;
|
||||
--good: #7dd87d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
.fixture-banner {
|
||||
background: repeating-linear-gradient(
|
||||
45deg, #3a1f00, #3a1f00 10px, #4a2800 10px, #4a2800 20px
|
||||
);
|
||||
color: var(--warn);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
font-size: 12px;
|
||||
border-bottom: 2px solid var(--warn);
|
||||
}
|
||||
|
||||
header.app-nav {
|
||||
background: var(--panel);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid #223;
|
||||
}
|
||||
|
||||
header.app-nav h1 {
|
||||
font-size: 18px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
header.app-nav nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
margin-right: 16px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
header.app-nav nav a.router-link-active {
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
main.app-main {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 { color: var(--accent); margin-bottom: 16px; font-size: 22px; }
|
||||
h3 { color: var(--text); margin: 16px 0 8px; font-size: 16px; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #223;
|
||||
}
|
||||
th { background: var(--panel-2); color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.status { padding: 2px 8px; border-radius: 10px; font-size: 11px; text-transform: uppercase; }
|
||||
.status.draft { background: #334; color: var(--muted); }
|
||||
.status.sent { background: #3a2c10; color: var(--warn); }
|
||||
.status.paid { background: #1e3a1e; color: var(--good); }
|
||||
.status.void { background: #3a1e1e; color: var(--danger); }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
|
||||
button, .btn {
|
||||
background: var(--accent);
|
||||
color: #0d1b2a;
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
button.ghost { background: transparent; color: var(--muted); border: 1px solid #334; }
|
||||
|
||||
a.link { color: var(--accent); text-decoration: none; }
|
||||
a.link:hover { text-decoration: underline; }
|
||||
|
||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
71
examples/fixture-invoicing/frontend/src/views/Customers.vue
Normal file
71
examples/fixture-invoicing/frontend/src/views/Customers.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<h2>Customers</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Add customer</h3>
|
||||
<div class="actions">
|
||||
<input v-model="draft.name" placeholder="Name" />
|
||||
<input v-model="draft.email" placeholder="Email" />
|
||||
<button @click="add" :disabled="!draft.name || !draft.email">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="customers.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in customers" :key="c.id">
|
||||
<td>{{ c.id }}</td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.email }}</td>
|
||||
<td>
|
||||
<button class="ghost" @click="remove(c.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No customers yet.</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const customers = ref([]);
|
||||
const draft = reactive({ name: "", email: "" });
|
||||
|
||||
async function load() {
|
||||
customers.value = await api.listCustomers();
|
||||
}
|
||||
|
||||
async function add() {
|
||||
await api.createCustomer({ name: draft.name, email: draft.email });
|
||||
draft.name = "";
|
||||
draft.email = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await api.deleteCustomer(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
min-width: 180px;
|
||||
}
|
||||
</style>
|
||||
148
examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue
Normal file
148
examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div v-if="invoice">
|
||||
<h2>
|
||||
Invoice {{ invoice.number }}
|
||||
<span :class="['status', invoice.status]">{{ invoice.status }}</span>
|
||||
</h2>
|
||||
<p class="muted">
|
||||
Customer: <strong>{{ invoice.customer.name }}</strong> ({{ invoice.customer.email }})
|
||||
· Issued {{ invoice.issued_at?.slice(0, 10) }}
|
||||
<span v-if="invoice.due_at"> · Due {{ invoice.due_at.slice(0, 10) }}</span>
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<h3>Line items</h3>
|
||||
<table v-if="invoice.line_items.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Qty</th>
|
||||
<th>Unit price</th>
|
||||
<th>Total</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="li in invoice.line_items" :key="li.id">
|
||||
<td>{{ li.description }}</td>
|
||||
<td>{{ li.quantity }}</td>
|
||||
<td>${{ li.unit_price }}</td>
|
||||
<td>${{ (li.quantity * li.unit_price).toFixed(2) }}</td>
|
||||
<td><button class="ghost" @click="removeLine(li.id)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No line items.</p>
|
||||
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<input v-model="lineDraft.description" placeholder="Description" />
|
||||
<input type="number" v-model.number="lineDraft.quantity" style="width: 80px;" />
|
||||
<input type="number" step="0.01" v-model.number="lineDraft.unit_price" style="width: 120px;" />
|
||||
<button @click="addLine" :disabled="!lineDraft.description">Add line</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Payments</h3>
|
||||
<table v-if="invoice.payments.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Paid at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in invoice.payments" :key="p.id">
|
||||
<td>${{ p.amount }}</td>
|
||||
<td>{{ p.method }}</td>
|
||||
<td>{{ p.paid_at?.slice(0, 10) }}</td>
|
||||
<td><button class="ghost" @click="removePayment(p.id)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No payments.</p>
|
||||
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<input type="number" step="0.01" v-model.number="payDraft.amount" placeholder="Amount" />
|
||||
<select v-model="payDraft.method">
|
||||
<option value="cash">cash</option>
|
||||
<option value="card">card</option>
|
||||
<option value="transfer">transfer</option>
|
||||
</select>
|
||||
<button @click="addPayment" :disabled="!payDraft.amount">Record payment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
Total billed: <strong>${{ totalBilled.toFixed(2) }}</strong>
|
||||
· Total paid: <strong>${{ totalPaid.toFixed(2) }}</strong>
|
||||
· Balance: <strong>${{ (totalBilled - totalPaid).toFixed(2) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="muted">Loading…</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const props = defineProps({ id: { type: [String, Number], required: true } });
|
||||
|
||||
const invoice = ref(null);
|
||||
const lineDraft = reactive({ description: "", quantity: 1, unit_price: 0 });
|
||||
const payDraft = reactive({ amount: 0, method: "cash" });
|
||||
|
||||
const totalBilled = computed(() =>
|
||||
(invoice.value?.line_items || []).reduce(
|
||||
(sum, li) => sum + Number(li.quantity) * Number(li.unit_price), 0
|
||||
)
|
||||
);
|
||||
const totalPaid = computed(() =>
|
||||
(invoice.value?.payments || []).reduce(
|
||||
(sum, p) => sum + Number(p.amount), 0
|
||||
)
|
||||
);
|
||||
|
||||
async function load() {
|
||||
invoice.value = await api.getInvoice(props.id);
|
||||
}
|
||||
|
||||
async function addLine() {
|
||||
await api.addLineItem(props.id, { ...lineDraft });
|
||||
lineDraft.description = "";
|
||||
lineDraft.quantity = 1;
|
||||
lineDraft.unit_price = 0;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeLine(id) {
|
||||
await api.deleteLineItem(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function addPayment() {
|
||||
await api.recordPayment(props.id, { ...payDraft });
|
||||
payDraft.amount = 0;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removePayment(id) {
|
||||
await api.deletePayment(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
watch(() => props.id, load);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input, .actions select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
93
examples/fixture-invoicing/frontend/src/views/Invoices.vue
Normal file
93
examples/fixture-invoicing/frontend/src/views/Invoices.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<h2>Invoices</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Create invoice</h3>
|
||||
<div class="actions">
|
||||
<input v-model="draft.number" placeholder="Number (e.g. DEMO-2026-004)" />
|
||||
<select v-model="draft.customer_id">
|
||||
<option :value="null" disabled>Customer…</option>
|
||||
<option v-for="c in customers" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="add" :disabled="!draft.number || !draft.customer_id">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="invoices.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Number</th>
|
||||
<th>Customer</th>
|
||||
<th>Issued</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in invoices" :key="inv.id">
|
||||
<td>{{ inv.id }}</td>
|
||||
<td>
|
||||
<router-link :to="`/invoices/${inv.id}`" class="link">
|
||||
{{ inv.number }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ customerName(inv.customer_id) }}</td>
|
||||
<td>{{ inv.issued_at?.slice(0, 10) }}</td>
|
||||
<td><span :class="['status', inv.status]">{{ inv.status }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No invoices yet.</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, computed } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const invoices = ref([]);
|
||||
const customers = ref([]);
|
||||
const draft = reactive({ number: "", customer_id: null });
|
||||
|
||||
const customerById = computed(() =>
|
||||
Object.fromEntries(customers.value.map((c) => [c.id, c]))
|
||||
);
|
||||
|
||||
function customerName(id) {
|
||||
return customerById.value[id]?.name || `#${id}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[invoices.value, customers.value] = await Promise.all([
|
||||
api.listInvoices(),
|
||||
api.listCustomers(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function add() {
|
||||
await api.createInvoice({
|
||||
number: draft.number,
|
||||
customer_id: draft.customer_id,
|
||||
status: "draft",
|
||||
});
|
||||
draft.number = "";
|
||||
draft.customer_id = null;
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input, .actions select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
12
examples/fixture-invoicing/frontend/vite.config.js
Normal file
12
examples/fixture-invoicing/frontend/vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
allowedHosts: true,
|
||||
watch: { usePolling: true },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user