Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions backend/alembic/versions/0002_batch_monthly_fee.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
68 changes: 68 additions & 0 deletions backend/app/routers/reports.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
Expand All @@ -6,14 +8,80 @@
from ..deps import get_current_user, require_staff
from ..models import (
Attendance,
Batch,
BatchEnrollment,
Payment,
Session as ClassSession,
Student,
Tutor,
User,
)
from ..routers.tutors import _visible_tutor_id

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)):
Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
17 changes: 17 additions & 0 deletions backend/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
3 changes: 3 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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"] },
Expand Down Expand Up @@ -162,6 +164,7 @@ export default function App() {
<Route path="/attendance" element={<Guard roles={["admin", "staff", "tutor"]}><Attendance /></Guard>} />
<Route path="/payments" element={<Guard roles={staff}><Payments /></Guard>} />
<Route path="/payments/:id" element={<Guard roles={staff}><PaymentInvoice /></Guard>} />
<Route path="/dues" element={<Guard roles={staff}><Dues /></Guard>} />
<Route path="/users" element={<Guard roles={["admin"]}><Users /></Guard>} />
<Route path="/settings" element={<Guard roles={["admin"]}><Settings /></Guard>} />
<Route path="/audit" element={<Guard roles={["admin"]}><Audit /></Guard>} />
Expand Down
25 changes: 18 additions & 7 deletions frontend/src/pages/Batches.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,7 +31,10 @@ function BatchCard({ batch, allStudents, onEdit, onChanged }) {
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-display text-lg font-semibold text-ink">{batch.name}</div>
<div className="text-sm text-muted">{batch.classes_per_week}× per week</div>
<div className="text-sm text-muted">
{batch.classes_per_week}× per week
{batch.monthly_fee != null && ` · ${inr(batch.monthly_fee)}/mo`}
</div>
</div>
<div className="flex gap-2 text-sm">
<button className="text-terracotta hover:underline" onClick={() => onEdit(batch)}>Edit</button>
Expand Down Expand Up @@ -65,25 +68,33 @@ 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);
list.reload();
}

return (
<Page title="Batches" actions={<button className="btn" onClick={() => setForm({ name: "", classes_per_week: 1 })}>+ New batch</button>}>
<Page title="Batches" actions={<button className="btn" onClick={() => setForm(blank)}>+ New batch</button>}>
{form && (
<form onSubmit={save} className="card mb-4 grid gap-3 md:grid-cols-2">
<input className="input" placeholder="Batch name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<label className="text-sm">Classes per week
<input className="input mt-1" type="number" min="1" required value={form.classes_per_week} onChange={(e) => setForm({ ...form, classes_per_week: e.target.value })} />
</label>
<label className="text-sm">Monthly fee (₹) — optional
<input className="input mt-1" type="number" min="0" step="any" placeholder="e.g. 800" value={form.monthly_fee} onChange={(e) => setForm({ ...form, monthly_fee: e.target.value })} />
</label>
<div className="flex gap-2 md:col-span-2">
<button className="btn">{form.id ? "Update" : "Save"}</button>
<button type="button" className="btn-ghost" onClick={() => setForm(null)}>Cancel</button>
Expand All @@ -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()}
/>
))}
Expand All @@ -107,7 +118,7 @@ export default function Batches() {
<EmptyState
title="No batches yet"
hint="Create a batch to group students and mark their attendance."
action={<button className="btn" onClick={() => setForm({ name: "", classes_per_week: 1 })}>+ New batch</button>}
action={<button className="btn" onClick={() => setForm(blank)}>+ New batch</button>}
/>
)}
</Page>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Panel title="Outstanding this month" link="/dues">
{d.count ? (
<div>
<div className="font-display text-3xl font-semibold text-clay">{inr(d.total_outstanding)}</div>
<div className="text-sm text-muted">{d.count} {d.count === 1 ? "student owes" : "students owe"} fees</div>
</div>
) : <p className="text-sm text-muted">No outstanding fees. 🎉</p>}
</Panel>
);
}

function AttendanceSnapshot() {
const att = useApi(() => api.get("/reports/attendance-summary"));
const d = att.data || {};
Expand Down Expand Up @@ -191,6 +207,7 @@ function StaffDashboard() {
<Animate delay={60} className="mt-6 grid gap-4 lg:grid-cols-2">
<TodaysClasses batches={batches.data} />
<AttendanceSnapshot />
<DuesSummary />
<BatchEnrollment batches={batches.data} />
<RecentPayments students={students.data} />
</Animate>
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/pages/Dues.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Page
title="Dues"
actions={
<input
className="input"
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
aria-label="Month"
/>
}
>
<Card className="mb-4 flex items-baseline justify-between">
<div>
<div className="text-sm text-muted">Outstanding for {fmtMonth(month)}</div>
<div className="font-display text-3xl font-semibold text-ink">{inr(data.total_outstanding)}</div>
</div>
<div className="text-sm text-muted">{data.count} {data.count === 1 ? "student" : "students"}</div>
</Card>

{dues.error ? (
<div className="card text-sm text-red-700">Could not load dues: {dues.error}</div>
) : rows.length ? (
<Table
columns={[
{ label: "Student", sort: (r) => 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) => (
<>
<td className="td font-medium text-ink">{r.student_name}</td>
<td className="td text-muted">{r.batch_name}</td>
<td className="td text-muted">{r.guardian_phone || "—"}</td>
<td className="td text-right">{inr(r.fee)}</td>
<td className="td text-right text-muted">{inr(r.paid)}</td>
<td className="td text-right font-semibold text-clay">{inr(r.outstanding)}</td>
<td className="td text-right">
<Link className="text-sm text-terracotta hover:underline" to="/payments">Record payment</Link>
</td>
</>
)}
/>
) : (
<EmptyState
title="All settled"
hint={`No outstanding fees for ${fmtMonth(month)}. Set a monthly fee on a batch to track its dues.`}
/>
)}
</Page>
);
}