🎉 v1.0.0 — Vela Platform launch

Self-hosted P&L tracking app with component-level pricing.
Offers: Atlas/Atlas+/Rif/Rif+ with granular cost breakdown.
API + MCP + multi-user auth.
This commit is contained in:
2026-06-15 23:05:59 +01:00
commit d1160673a7
14 changed files with 2387 additions and 0 deletions

111
backend/auth.py Normal file
View File

@@ -0,0 +1,111 @@
"""Authentication: password hashing, JWT tokens, and FastAPI dependencies."""
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from database import get_db
from models import User
# Secret key for JWT signing (generate a random one if not set)
SECRET_KEY = os.environ.get("JWT_SECRET", "bestof-manager-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def create_access_token(user_id: int, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
payload = {
"sub": str(user_id),
"role": role,
"exp": expire,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def _decode_token(token: str) -> Optional[dict]:
"""Try to decode a JWT token, return payload or None."""
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except (JWTError, ValueError, TypeError):
return None
def _token_to_user(token: str, db: Session) -> Optional[User]:
"""Given a token string, return User or None."""
payload = _decode_token(token)
if payload is None:
return None
try:
user_id = int(payload.get("sub"))
except (ValueError, TypeError):
return None
return db.query(User).filter(User.id == user_id).first()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
"""FastAPI dependency: validates JWT Bearer token and returns the current User."""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user = _token_to_user(credentials.credentials, db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
return user
def get_page_user(
request: Request,
db: Session = Depends(get_db),
) -> Optional[User]:
"""FastAPI dependency for page routes: checks cookie first, then Bearer header.
Returns None (not 401) if not authenticated — caller should redirect to /login.
"""
token = request.cookies.get("token")
if token:
user = _token_to_user(token, db)
if user:
return user
# Fallback: check Authorization header (for AJAX page loads)
auth = request.headers.get("Authorization")
if auth and auth.startswith("Bearer "):
user = _token_to_user(auth[7:], db)
if user:
return user
return None
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""FastAPI dependency: requires admin role."""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user

22
backend/database.py Normal file
View File

@@ -0,0 +1,22 @@
"""Database setup: engine, session factory, and base model."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "sqlite:///bestof.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()

640
backend/main.py Normal file
View File

@@ -0,0 +1,640 @@
"""Vela Platform — FastAPI application with API endpoints and Jinja2 frontend."""
from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from auth import (
create_access_token,
get_current_user,
get_page_user,
hash_password,
require_admin,
verify_password,
)
from database import Base, SessionLocal, engine, get_db
from models import Service, ServiceComponent, Transaction, User
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
app = FastAPI(title="Vela Platform", version="1.0.0")
# Mount templates via Jinja2
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
HARDCODED_OPEX = 800.0 # MAD/month
# ---------------------------------------------------------------------------
# Helper: serialize a service with its components
# ---------------------------------------------------------------------------
def service_to_dict(svc: Service) -> dict:
return {
"id": svc.id,
"name": svc.name,
"description": svc.description,
"sell_price": svc.sell_price,
"cost_price": svc.cost_price,
"active": svc.active,
"components": [
{
"id": c.id,
"name": c.name,
"unit_cost": c.unit_cost,
"unit_sell": c.unit_sell,
"quantity": c.quantity,
"notes": c.notes,
}
for c in svc.components
],
}
# ---------------------------------------------------------------------------
# Startup: create tables + seed data + create component tables
# ---------------------------------------------------------------------------
COMPONENT_TEMPLATES = {
"Atlas": [
("HDD (1TB)", 600.0, 1000.0, 1, "Enterprise HDD"),
("RAM (16GB)", 300.0, 500.0, 1, "ECC RAM"),
("CPU alloc", 200.0, 400.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Setup & config", 300.0, 600.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
"Atlas+": [
("HDD (1TB)", 600.0, 1000.0, 1, "Enterprise HDD"),
("SSD (256GB)", 400.0, 700.0, 1, "Cache SSD"),
("RAM (32GB)", 600.0, 1000.0, 1, "ECC RAM"),
("CPU alloc", 300.0, 600.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Sauvegarde", 300.0, 500.0, 1, "Backup service"),
("Setup & config", 200.0, 400.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
"Rif": [
("SSD (500GB)", 300.0, 600.0, 1, "NVMe SSD"),
("RAM (16GB)", 300.0, 500.0, 1, "ECC RAM"),
("CPU alloc", 200.0, 400.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Setup & config", 300.0, 600.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
"Rif+": [
("SSD (500GB)", 300.0, 600.0, 1, "NVMe SSD"),
("HDD (1TB)", 600.0, 1000.0, 1, "Enterprise HDD"),
("RAM (32GB)", 600.0, 1000.0, 1, "ECC RAM"),
("CPU alloc", 300.0, 600.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Sauvegarde", 300.0, 500.0, 1, "Backup service"),
("Setup & config", 200.0, 400.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
}
def seed_database():
"""Create tables and insert preseed data if missing."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# Seed users
if not db.query(User).filter(User.username == "admin").first():
db.add(
User(
username="admin",
password_hash=hash_password("admin123"),
display_name="Administrator",
role="admin",
)
)
if not db.query(User).filter(User.username == "viewer").first():
db.add(
User(
username="viewer",
password_hash=hash_password("viewer123"),
display_name="Viewer",
role="viewer",
)
)
# Seed services + components
for name, desc, sell, cost in [
("Atlas", "Core storage service", 4000.0, 2000.0),
("Atlas+", "Premium storage + backup service", 5000.0, 2800.0),
("Rif", "Core server service", 3500.0, 1700.0),
("Rif+", "Premium server + backup service", 4500.0, 2200.0),
]:
existing = db.query(Service).filter(Service.name == name).first()
if not existing:
svc = Service(
name=name,
description=desc,
sell_price=sell,
cost_price=cost,
active=True,
)
db.add(svc)
db.flush() # get the id
# Add component templates
for cname, ucost, usell, qty, notes in COMPONENT_TEMPLATES.get(name, []):
db.add(
ServiceComponent(
service_id=svc.id,
name=cname,
unit_cost=ucost,
unit_sell=usell,
quantity=qty,
notes=notes,
)
)
db.commit()
finally:
db.close()
seed_database()
# ---------------------------------------------------------------------------
# Helper: compute monthly P&L
# ---------------------------------------------------------------------------
def compute_pnl(month: str, db: Session) -> dict:
"""Compute P&L for a given YYYY-MM month."""
transactions = db.query(Transaction).filter(Transaction.month == month).all()
total_revenue = 0.0
total_cost = 0.0
for txn in transactions:
total_revenue += txn.revenue
total_cost += txn.cost
gross_profit = total_revenue - total_cost
gross_margin = (gross_profit / total_revenue * 100) if total_revenue > 0 else 0.0
opex = HARDCODED_OPEX
net_profit = gross_profit - opex
net_margin = (net_profit / total_revenue * 100) if total_revenue > 0 else 0.0
return {
"month": month,
"total_revenue": round(total_revenue, 2),
"total_cost": round(total_cost, 2),
"gross_profit": round(gross_profit, 2),
"gross_margin_pct": round(gross_margin, 2),
"opex": opex,
"net_profit": round(net_profit, 2),
"net_margin_pct": round(net_margin, 2),
}
def get_trend(db: Session, months: int = 12) -> list:
"""Return P&L for the last N months (those with transactions)."""
month_rows = (
db.query(Transaction.month)
.distinct()
.order_by(Transaction.month.desc())
.limit(months)
.all()
)
month_list = [row[0] for row in reversed(month_rows)]
return [compute_pnl(m, db) for m in month_list]
# ---------------------------------------------------------------------------
# API: Auth
# ---------------------------------------------------------------------------
@app.post("/api/auth/login")
def api_login(
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
token = create_access_token(user.id, user.role)
response = JSONResponse({
"token": token,
"user": {
"id": user.id,
"username": user.username,
"display_name": user.display_name,
"role": user.role,
},
})
response.set_cookie(
key="token",
value=token,
httponly=True,
max_age=86400,
samesite="lax",
secure=False,
path="/",
)
return response
@app.get("/api/auth/me")
def api_me(current_user: User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"display_name": current_user.display_name,
"role": current_user.role,
}
# ---------------------------------------------------------------------------
# API: Services
# ---------------------------------------------------------------------------
@app.get("/api/services")
def api_list_services(db: Session = Depends(get_db)):
return [service_to_dict(s) for s in db.query(Service).order_by(Service.name).all()]
@app.get("/api/services/{service_id}")
def api_get_service(service_id: int, db: Session = Depends(get_db)):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
return service_to_dict(svc)
@app.post("/api/services")
def api_create_service(
name: str = Form(...),
description: str = Form(""),
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = Service(
name=name,
description=description,
sell_price=0.0,
cost_price=0.0,
active=True,
)
db.add(svc)
db.commit()
db.refresh(svc)
return service_to_dict(svc)
@app.put("/api/services/{service_id}")
def api_update_service(
service_id: int,
name: str = Form(...),
description: str = Form(""),
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
svc.name = name
svc.description = description
svc.recompute_prices()
db.commit()
return service_to_dict(svc)
@app.delete("/api/services/{service_id}")
def api_deactivate_service(
service_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
svc.active = False
db.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# API: Service Components
# ---------------------------------------------------------------------------
@app.get("/api/services/{service_id}/components")
def api_list_components(service_id: int, db: Session = Depends(get_db)):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
return [
{
"id": c.id,
"name": c.name,
"unit_cost": c.unit_cost,
"unit_sell": c.unit_sell,
"quantity": c.quantity,
"notes": c.notes,
}
for c in svc.components
]
class ComponentInput(BaseModel):
name: str
unit_cost: float = 0.0
unit_sell: float = 0.0
quantity: int = 1
notes: str = ""
@app.post("/api/services/{service_id}/components")
def api_create_component(
service_id: int,
input: ComponentInput,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
comp = ServiceComponent(
service_id=service_id,
name=input.name,
unit_cost=input.unit_cost,
unit_sell=input.unit_sell,
quantity=input.quantity,
notes=input.notes,
)
db.add(comp)
db.flush()
svc.recompute_prices()
db.commit()
db.refresh(comp)
return {
"id": comp.id,
"name": comp.name,
"unit_cost": comp.unit_cost,
"unit_sell": comp.unit_sell,
"quantity": comp.quantity,
"notes": comp.notes,
}
@app.put("/api/components/{component_id}")
def api_update_component(
component_id: int,
input: ComponentInput,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
if not comp:
raise HTTPException(status_code=404, detail="Component not found")
comp.name = input.name
comp.unit_cost = input.unit_cost
comp.unit_sell = input.unit_sell
comp.quantity = input.quantity
comp.notes = input.notes
db.flush()
svc = db.query(Service).filter(Service.id == comp.service_id).first()
if svc:
svc.recompute_prices()
db.commit()
return {
"id": comp.id,
"name": comp.name,
"unit_cost": comp.unit_cost,
"unit_sell": comp.unit_sell,
"quantity": comp.quantity,
"notes": comp.notes,
}
@app.delete("/api/components/{component_id}")
def api_delete_component(
component_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
if not comp:
raise HTTPException(status_code=404, detail="Component not found")
svc = db.query(Service).filter(Service.id == comp.service_id).first()
db.delete(comp)
db.flush() # <-- ensures the deleted component is removed before recompute
if svc:
svc.recompute_prices()
db.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# API: Transactions
# ---------------------------------------------------------------------------
@app.get("/api/transactions")
def api_list_transactions(
month: Optional[str] = None,
db: Session = Depends(get_db),
):
query = db.query(Transaction)
if month:
query = query.filter(Transaction.month == month)
transactions = query.order_by(Transaction.created_at.desc()).all()
return [
{
"id": t.id,
"service_id": t.service_id,
"service_name": t.service.name,
"quantity": t.quantity,
"revenue": t.revenue,
"cost": t.cost,
"month": t.month,
"notes": t.notes,
"created_by": t.created_by,
"creator_name": t.creator.display_name,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in transactions
]
class CreateTransactionInput(BaseModel):
service_id: int
quantity: int
month: str
notes: str = ""
@app.post("/api/transactions")
def api_create_transaction(
input: CreateTransactionInput,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = db.query(Service).filter(Service.id == input.service_id).first()
if not service or not service.active:
raise HTTPException(status_code=400, detail="Invalid or inactive service")
txn = Transaction(
service_id=input.service_id,
quantity=input.quantity,
month=input.month,
notes=input.notes,
created_by=current_user.id,
)
db.add(txn)
db.commit()
db.refresh(txn)
return {
"id": txn.id,
"service_id": txn.service_id,
"service_name": txn.service.name,
"quantity": txn.quantity,
"revenue": txn.revenue,
"cost": txn.cost,
"month": txn.month,
"notes": txn.notes,
"created_by": txn.created_by,
"created_at": txn.created_at.isoformat() if txn.created_at else None,
}
@app.delete("/api/transactions/{transaction_id}")
def api_delete_transaction(
transaction_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
txn = db.query(Transaction).filter(Transaction.id == transaction_id).first()
if not txn:
raise HTTPException(status_code=404, detail="Transaction not found")
db.delete(txn)
db.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# API: P&L
# ---------------------------------------------------------------------------
@app.get("/api/pnl")
def api_pnl(month: str, db: Session = Depends(get_db)):
return compute_pnl(month, db)
# ---------------------------------------------------------------------------
# API: Dashboard
# ---------------------------------------------------------------------------
@app.get("/api/dashboard")
def api_dashboard(db: Session = Depends(get_db)):
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
pnl = compute_pnl(current_month, db)
trend = get_trend(db, months=12)
return {
"current_month": pnl,
"trend": trend,
}
# ---------------------------------------------------------------------------
# Frontend Routes
# ---------------------------------------------------------------------------
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
"""Serve the login page."""
return templates.TemplateResponse(request, "login.html")
@app.get("/", response_class=HTMLResponse)
def dashboard_page(
request: Request,
current_user: Optional[User] = Depends(get_page_user),
db: Session = Depends(get_db),
):
"""Serve the dashboard page."""
if not current_user:
return RedirectResponse(url="/login")
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
pnl = compute_pnl(current_month, db)
trend = get_trend(db, months=12)
services = db.query(Service).filter(Service.active == True).all()
return templates.TemplateResponse(
request, "dashboard.html",
{
"current_user": current_user,
"pnl": pnl,
"trend": trend,
"services": services,
},
)
@app.get("/transactions", response_class=HTMLResponse)
def transactions_page(
request: Request,
current_user: Optional[User] = Depends(get_page_user),
db: Session = Depends(get_db),
):
"""Serve the transactions page."""
if not current_user:
return RedirectResponse(url="/login")
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
services = db.query(Service).filter(Service.active == True).all()
return templates.TemplateResponse(
request, "transactions.html",
{
"current_user": current_user,
"services": services,
"current_month": current_month,
},
)
@app.get("/services", response_class=HTMLResponse)
def services_page(
request: Request,
current_user: Optional[User] = Depends(get_page_user),
db: Session = Depends(get_db),
):
"""Serve the services management page."""
if not current_user:
return RedirectResponse(url="/login")
services = db.query(Service).order_by(Service.name).all()
services_json = [service_to_dict(s) for s in services]
return templates.TemplateResponse(
request, "services.html",
{
"services": services,
"services_json": services_json,
"current_user": current_user,
},
)
# ---------------------------------------------------------------------------
# Logout
# ---------------------------------------------------------------------------
@app.get("/logout")
def logout():
return RedirectResponse(url="/login")
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8788, reload=True)

96
backend/models.py Normal file
View File

@@ -0,0 +1,96 @@
"""SQLAlchemy models: User, Service, ServiceComponent, Transaction."""
from datetime import datetime, timezone
from sqlalchemy import (
Column,
Integer,
String,
Float,
Boolean,
DateTime,
ForeignKey,
)
from sqlalchemy.orm import relationship
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
display_name = Column(String, nullable=False)
role = Column(String, nullable=False, default="viewer") # admin | viewer
transactions = relationship("Transaction", back_populates="creator")
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False)
description = Column(String, default="")
sell_price = Column(Float, nullable=False) # MAD/month — auto-computed from components
cost_price = Column(Float, nullable=False) # MAD/month — auto-computed from components
active = Column(Boolean, default=True)
transactions = relationship("Transaction", back_populates="service")
components = relationship(
"ServiceComponent",
back_populates="service",
cascade="all, delete-orphan",
order_by="ServiceComponent.id",
)
def recompute_prices(self):
"""Update sell_price and cost_price from component totals."""
self.sell_price = sum(
(c.unit_sell * c.quantity) for c in self.components
) if self.components else 0.0
self.cost_price = sum(
(c.unit_cost * c.quantity) for c in self.components
) if self.components else 0.0
class ServiceComponent(Base):
"""A cost/sell component of a service offer (e.g. RAM, HDD, Transport)."""
__tablename__ = "service_components"
id = Column(Integer, primary_key=True, index=True)
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
name = Column(String, nullable=False)
unit_cost = Column(Float, nullable=False, default=0.0) # what we pay / unit
unit_sell = Column(Float, nullable=False, default=0.0) # what we charge / unit
quantity = Column(Integer, nullable=False, default=1)
notes = Column(String, default="")
service = relationship("Service", back_populates="components")
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
month = Column(String, nullable=False) # YYYY-MM format
notes = Column(String, default="")
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
service = relationship("Service", back_populates="transactions")
creator = relationship("User", back_populates="transactions")
@property
def revenue(self) -> float:
"""Calculated: quantity * service.sell_price"""
return self.quantity * self.service.sell_price
@property
def cost(self) -> float:
"""Calculated: quantity * service.cost_price"""
return self.quantity * self.service.cost_price

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi>=0.104.0
uvicorn>=0.24.0
sqlalchemy>=2.0.0
bcrypt>=4.0.0
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.6
jinja2>=3.1.0

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vela Platform{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
body { padding-top: 70px; }
.navbar-brand { font-weight: 700; letter-spacing: -0.5px; }
.card-stat { border-left: 4px solid var(--bs-primary); }
.card-stat.green { border-left-color: var(--bs-success); }
.card-stat.red { border-left-color: var(--bs-danger); }
.card-stat.yellow { border-left-color: var(--bs-warning); }
.component-row td { vertical-align: middle; }
.component-row input { min-width: 60px; }
.total-label { font-weight: 700; font-size: 1.05rem; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary fixed-top border-bottom">
<div class="container">
<a class="navbar-brand" href="/">
🏔️ Vela Platform
</a>
{% if current_user %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/transactions"><i class="bi bi-list-ul"></i> Transactions</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/services"><i class="bi bi-gear"></i> Offers</a>
</li>
</ul>
<span class="navbar-text me-3">
<i class="bi bi-person-circle"></i> {{ current_user.display_name }}
<span class="badge bg-secondary ms-1">{{ current_user.role }}</span>
</span>
<a href="/logout" class="btn btn-outline-secondary btn-sm">Logout</a>
</div>
{% endif %}
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const TOKEN_KEY = 'vela_token';
function setToken(token) { localStorage.setItem(TOKEN_KEY, token); }
function getToken() { return localStorage.getItem(TOKEN_KEY); }
function clearToken() { localStorage.removeItem(TOKEN_KEY); }
async function apiFetch(url, options = {}) {
const token = getToken();
const headers = { ...options.headers };
if (token && !(options.body instanceof FormData)) {
headers['Authorization'] = `Bearer ${token}`;
headers['Content-Type'] = 'application/json';
} else if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const resp = await fetch(url, { ...options, headers });
if (resp.status === 401) {
clearToken();
window.location.href = '/login';
throw new Error('Unauthorized');
}
return resp;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}Dashboard — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-speedometer2"></i> Dashboard</h2>
<span class="badge bg-primary fs-6">{{ pnl.month }}</span>
</div>
<!-- P&L Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3 col-6">
<div class="card card-stat h-100">
<div class="card-body">
<small class="text-muted">Revenue</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.total_revenue) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat h-100">
<div class="card-body">
<small class="text-muted">Cost</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.total_cost) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat green h-100">
<div class="card-body">
<small class="text-muted">Gross Profit</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.gross_profit) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat {{ 'green' if pnl.net_profit >= 0 else 'red' }} h-100">
<div class="card-body">
<small class="text-muted">Net Profit</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.net_profit) }} MAD</h4>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Margin Breakdown</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td>Gross Margin</td><td class="text-end">{{ "%.1f"|format(pnl.gross_margin_pct) }}%</td></tr>
<tr><td>OpEx (fixed)</td><td class="text-end">{{ "%.0f"|format(pnl.opex) }} MAD</td></tr>
<tr class="fw-bold"><td>Net Margin</td><td class="text-end">{{ "%.1f"|format(pnl.net_margin_pct) }}%</td></tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Active Offers — Cost Breakdown</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Offer</th>
<th class="text-end">Sell</th>
<th class="text-end">Cost</th>
<th class="text-end">Margin</th>
<th class="text-end">Components</th>
</tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td class="text-end">{{ "%.0f"|format(s.sell_price) }} MAD</td>
<td class="text-end text-muted">{{ "%.0f"|format(s.cost_price) }} MAD</td>
<td class="text-end {{ 'text-success' if (s.sell_price - s.cost_price) > 0 else 'text-danger' }}">
{{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD
</td>
<td class="text-end text-muted">
<small>{{ s.components|length }} items</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 12-Month Trend Chart -->
<div class="card mb-4">
<div class="card-header">12-Month Trend</div>
<div class="card-body">
<canvas id="trendChart" height="80"></canvas>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const trendData = {{ trend | tojson }};
const labels = trendData.map(d => d.month);
const revenue = trendData.map(d => d.total_revenue);
const cost = trendData.map(d => d.total_cost);
const netProfit = trendData.map(d => d.net_profit);
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Revenue',
data: revenue,
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Cost',
data: cost,
borderColor: '#dc3545',
backgroundColor: 'rgba(220,53,69,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Net Profit',
data: netProfit,
borderColor: '#198754',
backgroundColor: 'rgba(25,135,84,0.1)',
fill: true,
tension: 0.3,
borderWidth: 2,
},
],
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
},
scales: {
y: {
ticks: { callback: v => v + ' MAD' },
},
},
},
});
</script>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Login — Vela Platform{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<h3 class="card-title text-center mb-4">🔐 Sign In</h3>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
placeholder="Enter username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Enter password" required>
</div>
<div id="loginError" class="alert alert-danger d-none py-2"></div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
<p class="text-muted text-center mt-3 mb-0" style="font-size: 0.85rem;">
Demo: admin / admin123 &nbsp;|&nbsp; viewer / viewer123
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const errorEl = document.getElementById('loginError');
errorEl.classList.add('d-none');
const formData = new FormData(form);
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const data = await resp.json();
errorEl.textContent = data.detail || 'Login failed';
errorEl.classList.remove('d-none');
return;
}
const data = await resp.json();
setToken(data.token);
window.location.href = '/';
} catch (err) {
errorEl.textContent = 'Network error. Please try again.';
errorEl.classList.remove('d-none');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,345 @@
{% extends "base.html" %}
{% block title %}Offers — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-gear"></i> Offers &amp; Pricing</h2>
{% if current_user.role == 'admin' %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#serviceModal"
onclick="resetServiceForm()">
<i class="bi bi-plus-lg"></i> Add Offer
</button>
{% endif %}
</div>
{% for s in services %}
<div class="card mb-4" id="svc-card-{{ s.id }}">
<!-- Service Header -->
<div class="card-header d-flex justify-content-between align-items-center"
style="cursor: pointer;" onclick="toggleService({{ s.id }})">
<div>
<strong class="fs-5">{{ s.name }}</strong>
<small class="text-muted ms-2">{{ s.description }}</small>
</div>
<div class="d-flex align-items-center gap-3">
<span class="badge bg-success fs-6">Sell: {{ "%.0f"|format(s.sell_price) }} MAD</span>
<span class="badge bg-danger fs-6">Cost: {{ "%.0f"|format(s.cost_price) }} MAD</span>
<span class="badge {{ 'bg-success' if (s.sell_price - s.cost_price) > 0 else 'bg-warning' }} fs-6">
Margin: {{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD
</span>
<span class="badge {{ 'bg-success' if s.active else 'bg-secondary' }}">
{{ 'Active' if s.active else 'Inactive' }}
</span>
<i class="bi bi-chevron-down" id="chevron-{{ s.id }}"></i>
</div>
</div>
<!-- Service Body (collapse) -->
<div id="svc-body-{{ s.id }}">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width:22%">Component</th>
<th style="width:14%">Unit Cost</th>
<th style="width:14%">Unit Sell</th>
<th style="width:8%">Qty</th>
<th style="width:14%">Row Cost</th>
<th style="width:14%">Row Sell</th>
<th style="width:8%">Margin</th>
{% if current_user.role == 'admin' %}
<th style="width:6%"></th>
{% endif %}
</tr>
</thead>
<tbody id="comp-tbody-{{ s.id }}">
{% for c in s.components %}
<tr class="component-row" id="comp-row-{{ c.id }}">
<td><strong>{{ c.name }}</strong>
{% if c.notes %}<br><small class="text-muted">{{ c.notes }}</small>{% endif %}
</td>
<td>{{ "%.0f"|format(c.unit_cost) }}</td>
<td>{{ "%.0f"|format(c.unit_sell) }}</td>
<td>{{ c.quantity }}</td>
<td>{{ "%.0f"|format(c.unit_cost * c.quantity) }}</td>
<td>{{ "%.0f"|format(c.unit_sell * c.quantity) }}</td>
<td class="{{ 'text-success' if (c.unit_sell - c.unit_cost) > 0 else 'text-danger' }}">
{{ "%.0f"|format(c.unit_sell - c.unit_cost) }}
</td>
{% if current_user.role == 'admin' %}
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary"
onclick="editComponent({{ c.id }}, '{{ c.name|e }}', {{ c.unit_cost }}, {{ c.unit_sell }}, {{ c.quantity }}, '{{ c.notes|e }}')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger"
onclick="deleteComponent({{ c.id }})">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr class="text-muted"><td colspan="8" class="text-center py-3">No components yet.</td></tr>
{% endfor %}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td>TOTAL</td>
<td></td>
<td></td>
<td></td>
<td id="tot-cost-{{ s.id }}">{{ "%.0f"|format(s.cost_price) }}</td>
<td id="tot-sell-{{ s.id }}">{{ "%.0f"|format(s.sell_price) }}</td>
<td id="tot-margin-{{ s.id }}"
class="{{ 'text-success' if (s.sell_price - s.cost_price) > 0 else 'text-danger' }}">
{{ "%.0f"|format(s.sell_price - s.cost_price) }}
</td>
{% if current_user.role == 'admin' %}
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="addComponent({{ s.id }})">
<i class="bi bi-plus-lg"></i>
</button>
</td>
{% endif %}
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-5">
<i class="bi bi-box" style="font-size: 3rem;"></i>
<p class="mt-3">No offers yet. Create your first one!</p>
</div>
</div>
{% endfor %}
{% if current_user.role == 'admin' %}
<!-- Add/Edit Service Modal -->
<div class="modal fade" id="serviceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="serviceForm">
<div class="modal-header">
<h5 class="modal-title" id="serviceModalTitle">Add Offer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="svcId" name="id" value="">
<div class="mb-3">
<label for="svcName" class="form-label">Offer Name</label>
<input type="text" class="form-control" id="svcName" name="name"
placeholder="e.g., Atlas" required>
</div>
<div class="mb-3">
<label for="svcDesc" class="form-label">Description</label>
<input type="text" class="form-control" id="svcDesc" name="description"
placeholder="Short description">
</div>
<div id="svcError" class="alert alert-danger d-none py-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add/Edit Component Modal -->
<div class="modal fade" id="compModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="compForm">
<div class="modal-header">
<h5 class="modal-title" id="compModalTitle">Add Component</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="compId" name="comp_id" value="">
<input type="hidden" id="compServiceId" name="service_id" value="">
<div class="mb-3">
<label for="compName" class="form-label">Component Name</label>
<input type="text" class="form-control" id="compName" name="name"
placeholder="e.g., RAM, HDD, Transport" required>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label for="compUnitCost" class="form-label">Unit Cost (MAD)</label>
<input type="number" class="form-control" id="compUnitCost" name="unit_cost"
step="0.01" min="0" value="0" required>
</div>
<div class="col-6">
<label for="compUnitSell" class="form-label">Unit Sell (MAD)</label>
<input type="number" class="form-control" id="compUnitSell" name="unit_sell"
step="0.01" min="0" value="0" required>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-4">
<label for="compQty" class="form-label">Quantity</label>
<input type="number" class="form-control" id="compQty" name="quantity"
min="1" value="1" required>
</div>
<div class="col-8">
<label for="compNotes" class="form-label">Notes</label>
<input type="text" class="form-control" id="compNotes" name="notes"
placeholder="Optional notes">
</div>
</div>
<div id="compError" class="alert alert-danger d-none py-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Component</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
{% if current_user.role == 'admin' %}
const compModal = new bootstrap.Modal(document.getElementById('compModal'));
const svcModal = new bootstrap.Modal(document.getElementById('serviceModal'));
// Toggle service body
function toggleService(id) {
const body = document.getElementById(`svc-body-${id}`);
const chevron = document.getElementById(`chevron-${id}`);
if (body.style.display === 'none') {
body.style.display = '';
chevron.className = 'bi bi-chevron-down';
} else {
body.style.display = 'none';
chevron.className = 'bi bi-chevron-up';
}
}
// Service CRUD
function resetServiceForm() {
document.getElementById('svcId').value = '';
document.getElementById('svcName').value = '';
document.getElementById('svcDesc').value = '';
document.getElementById('serviceModalTitle').textContent = 'Add Offer';
document.getElementById('svcError').classList.add('d-none');
}
document.getElementById('serviceForm').addEventListener('submit', async (e) => {
e.preventDefault();
const svcError = document.getElementById('svcError');
svcError.classList.add('d-none');
const formData = new FormData(e.target);
const id = formData.get('id');
const isEdit = !!id;
try {
const url = isEdit ? `/api/services/${id}` : '/api/services';
const method = isEdit ? 'PUT' : 'POST';
const resp = await apiFetch(url, { method, body: formData });
if (!resp.ok) {
const data = await resp.json();
svcError.textContent = data.detail || 'Failed to save offer';
svcError.classList.remove('d-none');
return;
}
svcModal.hide();
window.location.reload();
} catch (err) {
svcError.textContent = 'Error saving offer';
svcError.classList.remove('d-none');
}
});
// Component CRUD
function addComponent(serviceId) {
document.getElementById('compId').value = '';
document.getElementById('compServiceId').value = serviceId;
document.getElementById('compName').value = '';
document.getElementById('compUnitCost').value = '0';
document.getElementById('compUnitSell').value = '0';
document.getElementById('compQty').value = '1';
document.getElementById('compNotes').value = '';
document.getElementById('compModalTitle').textContent = 'Add Component';
document.getElementById('compError').classList.add('d-none');
compModal.show();
}
function editComponent(id, name, unitCost, unitSell, qty, notes) {
// Find the service ID from the card
const row = document.getElementById(`comp-row-${id}`);
const card = row.closest('.card');
const cardId = card.id; // svc-card-N
const svcId = parseInt(cardId.split('-')[2]);
document.getElementById('compId').value = id;
document.getElementById('compServiceId').value = svcId;
document.getElementById('compName').value = name;
document.getElementById('compUnitCost').value = unitCost;
document.getElementById('compUnitSell').value = unitSell;
document.getElementById('compQty').value = qty;
document.getElementById('compNotes').value = notes;
document.getElementById('compModalTitle').textContent = 'Edit Component';
document.getElementById('compError').classList.add('d-none');
compModal.show();
}
document.getElementById('compForm').addEventListener('submit', async (e) => {
e.preventDefault();
const compError = document.getElementById('compError');
compError.classList.add('d-none');
const compId = document.getElementById('compId').value;
const serviceId = document.getElementById('compServiceId').value;
const name = document.getElementById('compName').value;
const unitCost = parseFloat(document.getElementById('compUnitCost').value) || 0;
const unitSell = parseFloat(document.getElementById('compUnitSell').value) || 0;
const quantity = parseInt(document.getElementById('compQty').value) || 1;
const notes = document.getElementById('compNotes').value;
const body = JSON.stringify({ name, unit_cost: unitCost, unit_sell: unitSell, quantity, notes });
const isEdit = !!compId;
try {
const url = isEdit ? `/api/components/${compId}` : `/api/services/${serviceId}/components`;
const method = isEdit ? 'PUT' : 'POST';
const resp = await apiFetch(url, { method, body });
if (!resp.ok) {
const data = await resp.json();
compError.textContent = data.detail || 'Failed to save component';
compError.classList.remove('d-none');
return;
}
compModal.hide();
window.location.reload();
} catch (err) {
compError.textContent = 'Error saving component';
compError.classList.remove('d-none');
}
});
async function deleteComponent(id) {
if (!confirm('Delete this component?')) return;
try {
const resp = await apiFetch(`/api/components/${id}`, { method: 'DELETE' });
if (resp.ok) window.location.reload();
} catch (err) {
alert('Error deleting component');
}
}
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}Transactions — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-list-ul"></i> Transactions</h2>
</div>
<div class="row g-3">
<!-- Add Transaction Form -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">Add Transaction</div>
<div class="card-body">
<form id="txnForm">
<div class="mb-3">
<label for="serviceId" class="form-label">Service</label>
<select class="form-select" id="serviceId" name="service_id" required>
<option value="">Select service...</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }} ({{ "%.0f"|format(s.sell_price) }} MAD)</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" name="quantity"
value="1" min="1" required>
</div>
<div class="mb-3">
<label for="month" class="form-label">Month</label>
<input type="month" class="form-control" id="month" name="month"
value="{{ current_month }}" required>
</div>
<div class="mb-3">
<label for="notes" class="form-label">Notes</label>
<input type="text" class="form-control" id="notes" name="notes"
placeholder="Optional notes">
</div>
<div id="txnError" class="alert alert-danger d-none py-2"></div>
<div id="txnSuccess" class="alert alert-success d-none py-2"></div>
<button type="submit" class="btn btn-primary w-100">Add Transaction</button>
</form>
</div>
</div>
</div>
<!-- Transactions List -->
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Transaction History</span>
<div class="d-flex gap-2">
<input type="month" id="filterMonth" class="form-control form-control-sm"
style="width: auto;" value="{{ current_month }}">
<button class="btn btn-sm btn-outline-secondary" onclick="loadTransactions()">
<i class="bi bi-funnel"></i> Filter
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Service</th>
<th>Qty</th>
<th>Revenue</th>
<th>Cost</th>
<th>Month</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="txnTableBody">
<tr><td colspan="7" class="text-center text-muted py-3">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Monthly Summary -->
<div class="card-footer" id="monthlySummary" style="display:none;">
<div class="d-flex gap-4">
<span><strong>Revenue:</strong> <span id="sumRevenue">0</span> MAD</span>
<span><strong>Cost:</strong> <span id="sumCost">0</span> MAD</span>
<span><strong>Profit:</strong> <span id="sumProfit">0</span> MAD</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const txnForm = document.getElementById('txnForm');
const txnError = document.getElementById('txnError');
const txnSuccess = document.getElementById('txnSuccess');
txnForm.addEventListener('submit', async (e) => {
e.preventDefault();
txnError.classList.add('d-none');
txnSuccess.classList.add('d-none');
const formData = new FormData(txnForm);
try {
const resp = await apiFetch('/api/transactions', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const data = await resp.json();
txnError.textContent = data.detail || 'Failed to add transaction';
txnError.classList.remove('d-none');
return;
}
txnSuccess.textContent = 'Transaction added!';
txnSuccess.classList.remove('d-none');
txnForm.reset();
document.getElementById('month').value = '{{ current_month }}';
document.getElementById('quantity').value = '1';
loadTransactions();
setTimeout(() => txnSuccess.classList.add('d-none'), 3000);
} catch (err) {
txnError.textContent = 'Error adding transaction';
txnError.classList.remove('d-none');
}
});
async function loadTransactions() {
const month = document.getElementById('filterMonth').value;
const tbody = document.getElementById('txnTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Loading...</td></tr>';
try {
const url = month ? `/api/transactions?month=${month}` : '/api/transactions';
const resp = await apiFetch(url);
if (!resp.ok) throw new Error('Failed');
const data = await resp.json();
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">No transactions found</td></tr>';
document.getElementById('monthlySummary').style.display = 'none';
return;
}
let sumRevenue = 0, sumCost = 0;
tbody.innerHTML = data.map(t => {
sumRevenue += t.revenue;
sumCost += t.cost;
return `
<tr>
<td>${escapeHtml(t.service_name)}</td>
<td>${t.quantity}</td>
<td>${t.revenue.toFixed(0)} MAD</td>
<td>${t.cost.toFixed(0)} MAD</td>
<td>${t.month}</td>
<td><small>${escapeHtml(t.notes || '')}</small></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTxn(${t.id})"
${getToken() ? '' : 'disabled'}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}).join('');
document.getElementById('sumRevenue').textContent = sumRevenue.toFixed(0);
document.getElementById('sumCost').textContent = sumCost.toFixed(0);
document.getElementById('sumProfit').textContent = (sumRevenue - sumCost).toFixed(0);
document.getElementById('monthlySummary').style.display = '';
} catch (err) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-3">Error loading transactions</td></tr>';
}
}
async function deleteTxn(id) {
if (!confirm('Delete this transaction?')) return;
try {
const resp = await apiFetch(`/api/transactions/${id}`, { method: 'DELETE' });
if (resp.ok) loadTransactions();
} catch (err) {
alert('Error deleting transaction');
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Initial load
loadTransactions();
</script>
{% endblock %}