diff --git a/backend/alembic/versions/0002_batch_monthly_fee.py b/backend/alembic/versions/0002_batch_monthly_fee.py new file mode 100644 index 0000000..e03048d --- /dev/null +++ b/backend/alembic/versions/0002_batch_monthly_fee.py @@ -0,0 +1,32 @@ +"""batch monthly_fee + +Adds batches.monthly_fee for dues/fee tracking. + +Revision ID: 0002_batch_monthly_fee +Revises: 0001_baseline +Create Date: 2026-06-28 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002_batch_monthly_fee" +down_revision: Union[str, None] = "0001_baseline" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ponytail: guarded add — the 0001 baseline builds tables from live metadata + # (create_all), which already includes this column, so on a fresh DB the + # column exists; the guard makes `upgrade head` a safe no-op there. On an + # existing pre-feature DB the column is absent and gets added. + bind = op.get_bind() + cols = [c["name"] for c in sa.inspect(bind).get_columns("batches")] + if "monthly_fee" not in cols: + op.add_column("batches", sa.Column("monthly_fee", sa.Numeric(10, 2), nullable=True)) + + +def downgrade() -> None: + op.drop_column("batches", "monthly_fee") diff --git a/backend/app/models.py b/backend/app/models.py index 1fe4ed9..2329826 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -50,6 +50,7 @@ class Batch(Base): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String) classes_per_week: Mapped[int] = mapped_column(Integer, default=1) + monthly_fee: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py index b83236c..fccfaf1 100644 --- a/backend/app/routers/reports.py +++ b/backend/app/routers/reports.py @@ -1,3 +1,5 @@ +import re + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func from sqlalchemy.orm import Session @@ -6,7 +8,11 @@ from ..deps import get_current_user, require_staff from ..models import ( Attendance, + Batch, + BatchEnrollment, + Payment, Session as ClassSession, + Student, Tutor, User, ) @@ -14,6 +20,68 @@ router = APIRouter(prefix="/reports", tags=["reports"]) +MONTH_RE = re.compile(r"^\d{4}-\d{2}$") # YYYY-MM, same check the attendance grid uses + + +@router.get("/dues") +def dues( + month: str, + batch_id: int | None = None, + db: Session = Depends(get_db), + _=Depends(require_staff), +): + """Outstanding batch fees for a month: per active enrollment in a fee-bearing + batch, outstanding = monthly_fee - (sum of payments tagged that student/batch/month). + Two queries, no N+1. 'Expected payers' = currently-active enrollments (no + enrollment-date history exists to reconstruct mid-month joins).""" + if not MONTH_RE.match(month): + raise HTTPException(status_code=400, detail="month must be YYYY-MM") + enr = ( + db.query( + BatchEnrollment.student_id, Batch.id, Batch.name, + Batch.monthly_fee, Student.name, Student.guardian_phone, + ) + .join(Batch, Batch.id == BatchEnrollment.batch_id) + .join(Student, Student.id == BatchEnrollment.student_id) + .filter( + BatchEnrollment.is_active.is_(True), + Student.is_active.is_(True), + Batch.is_active.is_(True), + Batch.monthly_fee.isnot(None), + ) + ) + if batch_id: + enr = enr.filter(Batch.id == batch_id) + paid = { + (sid, bid): float(amt) + for sid, bid, amt in db.query( + Payment.student_id, + Payment.batch_id, + func.coalesce(func.sum(Payment.amount), 0), + ) + .filter(Payment.period_month == month) + .group_by(Payment.student_id, Payment.batch_id) + .all() + } + rows = [] + for sid, bid, bname, fee, sname, phone in enr.all(): + p = paid.get((sid, bid), 0.0) + outstanding = round(float(fee or 0) - p, 2) + if outstanding > 0: + rows.append({ + "student_id": sid, "student_name": sname, + "batch_id": bid, "batch_name": bname, "guardian_phone": phone, + "period_month": month, "fee": float(fee or 0), + "paid": p, "outstanding": outstanding, + }) + rows.sort(key=lambda r: (r["batch_name"], r["student_name"])) + return { + "month": month, + "count": len(rows), + "total_outstanding": round(sum(r["outstanding"] for r in rows), 2), + "rows": rows, + } + @router.get("/attendance-summary") def attendance_summary(db: Session = Depends(get_db), _=Depends(require_staff)): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 30286fc..92a0a3d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -67,6 +67,7 @@ class TutorOut(ORM, TutorBase): class BatchBase(BaseModel): name: str = Field(min_length=1) classes_per_week: int = Field(default=1, ge=1) + monthly_fee: float | None = Field(default=None, ge=0) # None = not fee-tracked class BatchCreate(BatchBase): diff --git a/backend/test_smoke.py b/backend/test_smoke.py index 7635789..dd22505 100644 --- a/backend/test_smoke.py +++ b/backend/test_smoke.py @@ -178,6 +178,23 @@ def run(): bat = next(b for b in batch_rows if b["id"] == bid) assert any(st["name"] == "Asha" for st in bat["students"]) and bat["student_count"] >= 1, bat + # Dues: a fee-bearing batch surfaces enrolled-but-unpaid students for a month + fb = c.post("/api/batches", json={"name": "Fee Batch", "classes_per_week": 1, "monthly_fee": 800}, headers=h).json() + assert fb["monthly_fee"] == 800.0, fb + fsid = c.post("/api/students", json={"name": "Payer"}, headers=h).json()["id"] + c.post("/api/students/enroll", json={"student_id": fsid, "batch_id": fb["id"]}, headers=h) + d1 = c.get("/api/reports/dues?month=2026-06", headers=h).json() + row = next(r for r in d1["rows"] if r["student_id"] == fsid and r["batch_id"] == fb["id"]) + assert row["outstanding"] == 800.0 and row["paid"] == 0.0, d1 + # recording the month's fee clears them from dues + c.post("/api/payments", json={"amount": 800, "method": "cash", "student_id": fsid, "batch_id": fb["id"], "period_month": "2026-06"}, headers=h) + d2 = c.get("/api/reports/dues?month=2026-06", headers=h).json() + assert not any(r["student_id"] == fsid and r["batch_id"] == fb["id"] for r in d2["rows"]), d2 + # bad month rejected; dues is staff-only + assert c.get("/api/reports/dues?month=nope", headers=h).status_code == 400 + assert c.get("/api/reports/dues?month=2026-06", headers=ph).status_code == 403 # parent + assert c.get("/api/reports/dues?month=2026-06", headers=th).status_code == 403 # tutor + print("smoke OK") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a7f8e4f..40302e5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ const SessionDetail = lazy(() => import("./pages/SessionDetail")); const Attendance = lazy(() => import("./pages/Attendance")); const Payments = lazy(() => import("./pages/Payments")); const PaymentInvoice = lazy(() => import("./pages/PaymentInvoice")); +const Dues = lazy(() => import("./pages/Dues")); const Users = lazy(() => import("./pages/Users")); const Settings = lazy(() => import("./pages/Settings")); const Account = lazy(() => import("./pages/Account")); @@ -35,6 +36,7 @@ const NAV = [ { to: "/sessions", label: "Sessions", roles: ["admin", "staff"] }, { to: "/attendance", label: "Attendance", roles: ["admin", "staff", "tutor"] }, { to: "/payments", label: "Payments", roles: ["admin", "staff"] }, + { to: "/dues", label: "Dues", roles: ["admin", "staff"] }, { to: "/users", label: "Users", roles: ["admin"] }, { to: "/settings", label: "Studio Details", roles: ["admin"] }, { to: "/audit", label: "Audit", roles: ["admin"] }, @@ -162,6 +164,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Batches.jsx b/frontend/src/pages/Batches.jsx index 08b0f4d..d7384a8 100644 --- a/frontend/src/pages/Batches.jsx +++ b/frontend/src/pages/Batches.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { api } from "../api"; -import { Page, Card, EmptyState, Stagger, useApi } from "../ui"; +import { Page, Card, EmptyState, Stagger, inr, useApi } from "../ui"; function BatchCard({ batch, allStudents, onEdit, onChanged }) { // Roster comes from the /batches list response (no per-card fetch); onChanged @@ -31,7 +31,10 @@ function BatchCard({ batch, allStudents, onEdit, onChanged }) {
{batch.name}
-
{batch.classes_per_week}× per week
+
+ {batch.classes_per_week}× per week + {batch.monthly_fee != null && ` · ${inr(batch.monthly_fee)}/mo`} +
@@ -65,11 +68,16 @@ function BatchCard({ batch, allStudents, onEdit, onChanged }) { export default function Batches() { const list = useApi(() => api.get("/batches")); const students = useApi(() => api.get("/students")); - const [form, setForm] = useState(null); // { id?, name, classes_per_week } + const [form, setForm] = useState(null); // { id?, name, classes_per_week, monthly_fee } + const blank = { name: "", classes_per_week: 1, monthly_fee: "" }; async function save(e) { e.preventDefault(); - const body = { name: form.name, classes_per_week: Number(form.classes_per_week) || 1 }; + const body = { + name: form.name, + classes_per_week: Number(form.classes_per_week) || 1, + monthly_fee: form.monthly_fee === "" ? null : Number(form.monthly_fee), + }; if (form.id) await api.put(`/batches/${form.id}`, body); else await api.post("/batches", body); setForm(null); @@ -77,13 +85,16 @@ export default function Batches() { } return ( - setForm({ name: "", classes_per_week: 1 })}>+ New batch}> + setForm(blank)}>+ New batch}> {form && (
setForm({ ...form, name: e.target.value })} /> +
@@ -98,7 +109,7 @@ export default function Batches() { key={b.id} batch={b} allStudents={students.data || []} - onEdit={(batch) => setForm({ id: batch.id, name: batch.name, classes_per_week: batch.classes_per_week })} + onEdit={(batch) => setForm({ id: batch.id, name: batch.name, classes_per_week: batch.classes_per_week, monthly_fee: batch.monthly_fee ?? "" })} onChanged={() => list.reload()} /> ))} @@ -107,7 +118,7 @@ export default function Batches() { setForm({ name: "", classes_per_week: 1 })}>+ New batch} + action={} /> )} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 69ef881..829a48d 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -113,6 +113,22 @@ function TodaysClasses({ batches }) { ); } +function DuesSummary() { + const month = todayISO().slice(0, 7); // YYYY-MM + const dues = useApi(() => api.get(`/reports/dues?month=${month}`)); + const d = dues.data || { count: 0, total_outstanding: 0 }; + return ( + + {d.count ? ( +
+
{inr(d.total_outstanding)}
+
{d.count} {d.count === 1 ? "student owes" : "students owe"} fees
+
+ ) :

No outstanding fees. 🎉

} +
+ ); +} + function AttendanceSnapshot() { const att = useApi(() => api.get("/reports/attendance-summary")); const d = att.data || {}; @@ -191,6 +207,7 @@ function StaffDashboard() { + diff --git a/frontend/src/pages/Dues.jsx b/frontend/src/pages/Dues.jsx new file mode 100644 index 0000000..688ccbc --- /dev/null +++ b/frontend/src/pages/Dues.jsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api"; +import { Page, Card, Table, EmptyState, inr, fmtMonth, useApi } from "../ui"; + +const thisMonth = () => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +}; + +export default function Dues() { + const [month, setMonth] = useState(thisMonth); + const dues = useApi(() => api.get(`/reports/dues?month=${month}`), [month]); + const data = dues.data || { rows: [], count: 0, total_outstanding: 0 }; + // Dues rows have no DB id; key on the student+batch pair. + const rows = data.rows.map((r) => ({ ...r, id: `${r.student_id}-${r.batch_id}` })); + + return ( + setMonth(e.target.value)} + aria-label="Month" + /> + } + > + +
+
Outstanding for {fmtMonth(month)}
+
{inr(data.total_outstanding)}
+
+
{data.count} {data.count === 1 ? "student" : "students"}
+
+ + {dues.error ? ( +
Could not load dues: {dues.error}
+ ) : rows.length ? ( + r.student_name }, + { label: "Batch", sort: (r) => r.batch_name }, + { label: "Phone" }, + { label: "Fee", align: "right", sort: (r) => r.fee }, + { label: "Paid", align: "right", sort: (r) => r.paid }, + { label: "Outstanding", align: "right", sort: (r) => r.outstanding }, + { label: "" }, + ]} + rows={rows} + render={(r) => ( + <> + + + + + + + + + )} + /> + ) : ( + + )} + + ); +}
{r.student_name}{r.batch_name}{r.guardian_phone || "—"}{inr(r.fee)}{inr(r.paid)}{inr(r.outstanding)} + Record payment +