🎉 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:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.db
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
32
CHANGELOG.md
Normal file
32
CHANGELOG.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.0 (2026-06-15) — Vela Platform Launch 🏔️
|
||||
|
||||
### 🚀 Initial Release
|
||||
- Self-hosted P&L tracking web app for Best OF (C2C server/storage business)
|
||||
- Built with FastAPI + SQLite + Jinja2 templates + Bootstrap 5
|
||||
|
||||
### 🏔️ Offers (Services)
|
||||
- **Atlas** — Core 1TB storage (7 components, 4,000 MAD/month)
|
||||
- **Atlas+** — Premium 1TB + backup (9 components, 5,000 MAD/month)
|
||||
- **Rif** — Core 500GB server (7 components, 3,500 MAD/month)
|
||||
- **Rif+** — Premium 500GB + backup (9 components, 4,500 MAD/month)
|
||||
|
||||
### 🔧 Features
|
||||
- **Component-Level Pricing** — Each offer has granular breakdown (HDD, SSD, RAM, CPU, Casing, Bandwidth, Setup, Support, Sauvegarde)
|
||||
- **Auto-Computed Prices** — Totals recalculate automatically when you edit any component
|
||||
- **Inline Component Editor** — Add, edit, or delete components from the Offers page with live price updates
|
||||
- **Monthly P&L Dashboard** — Revenue, cost, margin cards with 12-month trend chart
|
||||
- **Transaction Logging** — Track monthly revenue per client per service
|
||||
- **Multi-User Auth** — Admin (edit) + Viewer (read-only) roles
|
||||
- **JWT Cookie Auth** — Login sets httpOnly cookie, API uses Bearer token
|
||||
|
||||
### 🧩 API & Integrations
|
||||
- Full REST API for all CRUD operations
|
||||
- MCP server for Hermes agent integration (components, transactions, P&L)
|
||||
- All prices in MAD (Moroccan Dirham)
|
||||
|
||||
### 🐳 Deployment
|
||||
- Standalone Python app (no Docker needed)
|
||||
- systemd user service for auto-start
|
||||
- Single SQLite database (zero config)
|
||||
121
CLAUDE.md
Normal file
121
CLAUDE.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Vela Platform 🏔️
|
||||
|
||||
A lightweight self-hosted P&L tracking app for a C2C server/storage business.
|
||||
|
||||
## Stack
|
||||
- Python 3.11+ with FastAPI + uvicorn
|
||||
- SQLite via SQLAlchemy (single file, zero setup)
|
||||
- Jinja2 templates + Bootstrap 5 dark frontend
|
||||
- Chart.js for trend charts
|
||||
- JWT auth (cookie + Bearer header)
|
||||
- stdio MCP server for Hermes integration
|
||||
|
||||
## Data Model
|
||||
|
||||
### User
|
||||
- id, username, password_hash, display_name, role (admin|viewer)
|
||||
|
||||
### Service (Offer)
|
||||
- id, name (Atlas/Atlas+/Rif/Rif+), description
|
||||
- sell_price (computed from components), cost_price (computed from components)
|
||||
- active
|
||||
|
||||
### ServiceComponent
|
||||
- id, service_id (FK), name (RAM/HDD/SSD/etc.), unit_cost, unit_sell, quantity, notes
|
||||
- `recompute_prices()` method sums cost/sell from components
|
||||
|
||||
### Transaction
|
||||
- id, service_id (FK), quantity, revenue (calculated: qty * service.sell_price), cost (calculated: qty * service.cost_price), month (YYYY-MM), notes, created_by (FK user), created_at
|
||||
|
||||
### Monthly P&L (computed)
|
||||
- month, total_revenue, total_cost, gross_profit, gross_margin%, opex (800 MAD), net_profit, net_margin%
|
||||
|
||||
## Preseed Data
|
||||
- Users: admin (password: admin123), viewer (password: viewer123)
|
||||
- Services with components (see COMPONENT_TEMPLATES in main.py)
|
||||
- Opex: 800 MAD/month hardcoded
|
||||
|
||||
## Key Files
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `backend/main.py` | FastAPI app (API + frontend routes) |
|
||||
| `backend/models.py` | SQLAlchemy models |
|
||||
| `backend/auth.py` | JWT auth, cookie support |
|
||||
| `backend/database.py` | SQLite setup |
|
||||
| `backend/templates/` | Jinja2 templates |
|
||||
| `mcp/server.py` | MCP stdio server |
|
||||
| `CLAUDE.md` | This file |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
- POST /api/auth/login -> {token, user} + set cookie
|
||||
- GET /api/auth/me -> current user
|
||||
|
||||
### Services
|
||||
- GET /api/services -> list with components
|
||||
- GET /api/services/{id} -> single with components
|
||||
- POST /api/services -> create (admin)
|
||||
- PUT /api/services/{id} -> update (admin)
|
||||
- DELETE /api/services/{id} -> deactivate (admin)
|
||||
|
||||
### Components
|
||||
- GET /api/services/{id}/components -> list
|
||||
- POST /api/services/{id}/components -> create (admin, JSON body)
|
||||
- PUT /api/components/{id} -> update (admin, JSON body) — auto-recomputes prices
|
||||
- DELETE /api/components/{id} -> delete (admin) — auto-recomputes prices
|
||||
|
||||
### Transactions
|
||||
- GET /api/transactions?month=YYYY-MM -> list
|
||||
- POST /api/transactions -> create {service_id, quantity, month, notes}
|
||||
- DELETE /api/transactions/{id} -> (admin)
|
||||
|
||||
### P&L
|
||||
- GET /api/pnl?month=YYYY-MM -> computed P&L
|
||||
- GET /api/dashboard -> current + 12-month trend
|
||||
|
||||
## MCP Server (stdio)
|
||||
|
||||
Located at `mcp/server.py`. Run as a stdio MCP server via `python3 mcp/server.py`.
|
||||
|
||||
Tools: get_pnl, get_services, get_service, add_component, update_component, delete_component, add_transaction, get_dashboard
|
||||
Resources: vela://pnl/current, vela://services, vela://services/{id}
|
||||
|
||||
## URLs
|
||||
- Web app: http://192.168.1.30:8788
|
||||
- MCP: registered as `vela-platform` in Hermes config
|
||||
|
||||
## Component Pricing Templates
|
||||
|
||||
Each offer has its own set of components. The total sell/cost is auto-computed:
|
||||
|
||||
### Atlas (1TB Storage)
|
||||
HDD(1TB) 600/1000, RAM(16GB) 300/500, CPU alloc 200/400, Casing 200/300, Bandwidth 200/500, Setup 300/600, Support 200/700
|
||||
→ Total: cost 2000, sell 4000
|
||||
|
||||
### Atlas+ (1TB + Backup)
|
||||
HDD(1TB) 600/1000, SSD(256GB) 400/700, RAM(32GB) 600/1000, CPU alloc 300/600, Casing 200/300, Bandwidth 200/500, Sauvegarde 300/500, Setup 200/400, Support 200/700
|
||||
→ Total: cost 2800, sell 5000
|
||||
|
||||
### Rif (500GB Server)
|
||||
SSD(500GB) 300/600, RAM(16GB) 300/500, CPU alloc 200/400, Casing 200/300, Bandwidth 200/500, Setup 300/600, Support 200/700
|
||||
→ Total: cost 1700, sell 3500
|
||||
|
||||
### Rif+ (500GB + Backup)
|
||||
SSD(500GB) 300/600, HDD(1TB) 600/1000, RAM(32GB) 600/1000, CPU alloc 300/600, Casing 200/300, Bandwidth 200/500, Sauvegarde 300/500, Setup 200/400, Support 200/700
|
||||
→ Total: cost 2200, sell 4500
|
||||
|
||||
## Deployment
|
||||
```bash
|
||||
cd ~/bestof-manager/backend && uvicorn main:app --host 0.0.0.0 --port 8788
|
||||
# Or via systemd:
|
||||
systemctl --user restart bestof-manager.service
|
||||
```
|
||||
|
||||
## Auth Flow
|
||||
- Page routes use cookie auth (httpOnly 'token' cookie set on login)
|
||||
- API routes use Bearer token from Authorization header
|
||||
- Login sets both cookie AND returns JSON token
|
||||
- Token expires after 24h
|
||||
- Pages without valid cookie redirect to /login
|
||||
111
backend/auth.py
Normal file
111
backend/auth.py
Normal 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
22
backend/database.py
Normal 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
640
backend/main.py
Normal 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
96
backend/models.py
Normal 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
7
backend/requirements.txt
Normal 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
|
||||
91
backend/templates/base.html
Normal file
91
backend/templates/base.html
Normal 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>
|
||||
159
backend/templates/dashboard.html
Normal file
159
backend/templates/dashboard.html
Normal 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 %}
|
||||
60
backend/templates/login.html
Normal file
60
backend/templates/login.html
Normal 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 | 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 %}
|
||||
345
backend/templates/services.html
Normal file
345
backend/templates/services.html
Normal 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 & 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 %}
|
||||
196
backend/templates/transactions.html
Normal file
196
backend/templates/transactions.html
Normal 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 %}
|
||||
494
mcp/server.py
Normal file
494
mcp/server.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Vela Platform — MCP Server (stdio transport).
|
||||
|
||||
Provides tools and resources for interacting with the Vela Platform data.
|
||||
Includes component-level pricing breakdown for each offer.
|
||||
|
||||
Usage:
|
||||
python mcp/server.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
|
||||
|
||||
from database import SessionLocal
|
||||
from models import Service, ServiceComponent, Transaction
|
||||
|
||||
OPEX = 800.0
|
||||
|
||||
|
||||
def compute_pnl(month: str) -> dict:
|
||||
"""Compute P&L for a given month."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
transactions = db.query(Transaction).filter(Transaction.month == month).all()
|
||||
total_revenue = sum(t.revenue for t in transactions)
|
||||
total_cost = sum(t.cost for t in transactions)
|
||||
gross_profit = total_revenue - total_cost
|
||||
gross_margin = (gross_profit / total_revenue * 100) if total_revenue > 0 else 0.0
|
||||
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),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_services() -> list:
|
||||
"""Get all services with their component breakdowns."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
services = db.query(Service).order_by(Service.name).all()
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"sell_price": s.sell_price,
|
||||
"cost_price": s.cost_price,
|
||||
"active": s.active,
|
||||
"margin": round(s.sell_price - s.cost_price, 2),
|
||||
"components": [
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"unit_cost": c.unit_cost,
|
||||
"unit_sell": c.unit_sell,
|
||||
"quantity": c.quantity,
|
||||
"row_cost": round(c.unit_cost * c.quantity, 2),
|
||||
"row_sell": round(c.unit_sell * c.quantity, 2),
|
||||
"notes": c.notes,
|
||||
}
|
||||
for c in s.components
|
||||
],
|
||||
}
|
||||
for s in services
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_service(service_id: int) -> dict:
|
||||
"""Get a single service with components."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
s = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not s:
|
||||
return {"error": "Service not found"}
|
||||
return {
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"sell_price": s.sell_price,
|
||||
"cost_price": s.cost_price,
|
||||
"active": s.active,
|
||||
"components": [
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"unit_cost": c.unit_cost,
|
||||
"unit_sell": c.unit_sell,
|
||||
"quantity": c.quantity,
|
||||
"row_cost": round(c.unit_cost * c.quantity, 2),
|
||||
"row_sell": round(c.unit_sell * c.quantity, 2),
|
||||
}
|
||||
for c in s.components
|
||||
],
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def add_component(service_id: int, name: str, unit_cost: float, unit_sell: float,
|
||||
quantity: int = 1, notes: str = "") -> dict:
|
||||
"""Add a component to a service."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
svc = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not svc:
|
||||
return {"error": "Service not found"}
|
||||
comp = ServiceComponent(
|
||||
service_id=service_id, name=name,
|
||||
unit_cost=unit_cost, unit_sell=unit_sell,
|
||||
quantity=quantity, notes=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,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def update_component(component_id: int, name: str = None, unit_cost: float = None,
|
||||
unit_sell: float = None, quantity: int = None,
|
||||
notes: str = None) -> dict:
|
||||
"""Update a component."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
|
||||
if not comp:
|
||||
return {"error": "Component not found"}
|
||||
if name is not None:
|
||||
comp.name = name
|
||||
if unit_cost is not None:
|
||||
comp.unit_cost = unit_cost
|
||||
if unit_sell is not None:
|
||||
comp.unit_sell = unit_sell
|
||||
if quantity is not None:
|
||||
comp.quantity = quantity
|
||||
if notes is not None:
|
||||
comp.notes = notes
|
||||
db.flush()
|
||||
svc = db.query(Service).filter(Service.id == comp.service_id).first()
|
||||
if svc:
|
||||
svc.recompute_prices()
|
||||
db.commit()
|
||||
return {"ok": True, "id": comp.id}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def delete_component(component_id: int) -> dict:
|
||||
"""Delete a component."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
|
||||
if not comp:
|
||||
return {"error": "Component not found"}
|
||||
svc = db.query(Service).filter(Service.id == comp.service_id).first()
|
||||
db.delete(comp)
|
||||
db.flush()
|
||||
if svc:
|
||||
svc.recompute_prices()
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def add_transaction(service_id: int, quantity: int, month: str, notes: str = "") -> dict:
|
||||
"""Add a new transaction."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service or not service.active:
|
||||
return {"error": "Invalid or inactive service"}
|
||||
txn = Transaction(
|
||||
service_id=service_id, quantity=quantity,
|
||||
month=month, notes=notes, created_by=1,
|
||||
)
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return {
|
||||
"id": txn.id,
|
||||
"service_name": txn.service.name,
|
||||
"quantity": txn.quantity,
|
||||
"revenue": txn.revenue,
|
||||
"cost": txn.cost,
|
||||
"month": txn.month,
|
||||
"notes": txn.notes,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_dashboard() -> dict:
|
||||
"""Get current month dashboard overview."""
|
||||
from datetime import datetime, timezone
|
||||
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||
pnl = compute_pnl(current_month)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
month_rows = (
|
||||
db.query(Transaction.month)
|
||||
.distinct()
|
||||
.order_by(Transaction.month.desc())
|
||||
.limit(12)
|
||||
.all()
|
||||
)
|
||||
month_list = [row[0] for row in reversed(month_rows)]
|
||||
trend = [compute_pnl(m) for m in month_list]
|
||||
return {"current_month": pnl, "trend": trend}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Protocol Handler
|
||||
# ---------------------------------------------------------------------------
|
||||
def handle_request(request: dict) -> dict:
|
||||
method = request.get("method", "")
|
||||
req_id = request.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}, "resources": {}},
|
||||
"serverInfo": {"name": "vela-platform", "version": "1.0.0"},
|
||||
},
|
||||
}
|
||||
|
||||
if method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_pnl",
|
||||
"description": "Get P&L data for a given month (YYYY-MM format).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"month": {"type": "string", "description": "Month in YYYY-MM format"}
|
||||
},
|
||||
"required": ["month"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_services",
|
||||
"description": "List all offers (services) with full component pricing breakdown.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_service",
|
||||
"description": "Get a single offer (service) with its component breakdown.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_id": {"type": "integer", "description": "Service ID"}
|
||||
},
|
||||
"required": ["service_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_component",
|
||||
"description": "Add a pricing component (RAM, HDD, Transport, etc.) to an offer.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_id": {"type": "integer"},
|
||||
"name": {"type": "string", "description": "Component name"},
|
||||
"unit_cost": {"type": "number", "description": "Unit cost price"},
|
||||
"unit_sell": {"type": "number", "description": "Unit sell price"},
|
||||
"quantity": {"type": "integer", "description": "Quantity, default 1"},
|
||||
"notes": {"type": "string", "description": "Optional notes"},
|
||||
},
|
||||
"required": ["service_id", "name", "unit_cost", "unit_sell"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "update_component",
|
||||
"description": "Update a pricing component. Only provided fields are changed.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"unit_cost": {"type": "number"},
|
||||
"unit_sell": {"type": "number"},
|
||||
"quantity": {"type": "integer"},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": ["component_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "delete_component",
|
||||
"description": "Delete a pricing component from an offer.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_id": {"type": "integer"}
|
||||
},
|
||||
"required": ["component_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_transaction",
|
||||
"description": "Add a new transaction (sale).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_id": {"type": "integer"},
|
||||
"quantity": {"type": "integer"},
|
||||
"month": {"type": "string", "description": "YYYY-MM format"},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": ["service_id", "quantity", "month"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_dashboard",
|
||||
"description": "Get current month dashboard overview with 12-month trend.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
if method == "resources/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"resources": [
|
||||
{
|
||||
"uri": "vela://pnl/current",
|
||||
"name": "Current Month P&L",
|
||||
"description": "Profit & Loss for the current month",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "vela://services",
|
||||
"name": "Service List with Components",
|
||||
"description": "All offers with component pricing breakdown",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "vela://services/{id}",
|
||||
"name": "Single Service Detail",
|
||||
"description": "A specific offer with its components",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
if method == "resources/read":
|
||||
uri = request.get("params", {}).get("uri", "")
|
||||
if uri == "vela://pnl/current":
|
||||
from datetime import datetime, timezone
|
||||
month = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||
content = json.dumps(compute_pnl(month), indent=2)
|
||||
elif uri == "vela://services":
|
||||
content = json.dumps(get_services(), indent=2)
|
||||
elif uri.startswith("vela://services/"):
|
||||
try:
|
||||
svc_id = int(uri.split("/")[-1])
|
||||
content = json.dumps(get_service(svc_id), indent=2)
|
||||
except (ValueError, IndexError):
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32602, "message": f"Invalid resource URI: {uri}"},
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32602, "message": f"Unknown resource: {uri}"},
|
||||
}
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"contents": [{"uri": uri, "mimeType": "application/json", "text": content}]
|
||||
},
|
||||
}
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name = request.get("params", {}).get("name", "")
|
||||
args = request.get("params", {}).get("arguments", {})
|
||||
try:
|
||||
if tool_name == "get_pnl":
|
||||
result = compute_pnl(args["month"])
|
||||
elif tool_name == "get_services":
|
||||
result = get_services()
|
||||
elif tool_name == "get_service":
|
||||
result = get_service(args["service_id"])
|
||||
elif tool_name == "add_component":
|
||||
result = add_component(
|
||||
args["service_id"], args["name"],
|
||||
args["unit_cost"], args["unit_sell"],
|
||||
args.get("quantity", 1), args.get("notes", ""),
|
||||
)
|
||||
elif tool_name == "update_component":
|
||||
result = update_component(
|
||||
args["component_id"],
|
||||
args.get("name"), args.get("unit_cost"),
|
||||
args.get("unit_sell"), args.get("quantity"),
|
||||
args.get("notes"),
|
||||
)
|
||||
elif tool_name == "delete_component":
|
||||
result = delete_component(args["component_id"])
|
||||
elif tool_name == "add_transaction":
|
||||
result = add_transaction(
|
||||
args["service_id"], args["quantity"],
|
||||
args["month"], args.get("notes", ""),
|
||||
)
|
||||
elif tool_name == "get_dashboard":
|
||||
result = get_dashboard()
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
|
||||
}
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": json.dumps(result, indent=2)}]
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": json.dumps({"error": str(e)})}],
|
||||
"isError": True,
|
||||
},
|
||||
}
|
||||
|
||||
if "id" not in request:
|
||||
return None
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
from main import seed_database
|
||||
seed_database()
|
||||
sys.stderr.write("Vela Platform MCP Server starting...\n")
|
||||
sys.stderr.flush()
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
response = handle_request(request)
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response) + "\n")
|
||||
sys.stdout.flush()
|
||||
except json.JSONDecodeError as e:
|
||||
sys.stderr.write(f"JSON parse error: {e}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user