1
This commit is contained in:
parent
3d0b3ab8f6
commit
f707e3ec5c
127
backend/app/api/fund.py
Normal file
127
backend/app/api/fund.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import require_role
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.db.models import FundRecord, User
|
||||||
|
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics
|
||||||
|
from app.schemas.common import PageResponse
|
||||||
|
from app.services.fund_service import (
|
||||||
|
create_fund_record, update_fund_record, delete_fund_record,
|
||||||
|
get_fund_record_by_id, list_fund_records, get_fund_statistics,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/fund", tags=["fund"])
|
||||||
|
|
||||||
|
|
||||||
|
def record_to_out(record: FundRecord) -> FundRecordOut:
|
||||||
|
return FundRecordOut(
|
||||||
|
id=record.id,
|
||||||
|
class_id=record.class_id,
|
||||||
|
type=record.type,
|
||||||
|
amount=record.amount,
|
||||||
|
category=record.category,
|
||||||
|
description=record.description,
|
||||||
|
record_date=record.record_date,
|
||||||
|
recorder_id=record.recorder_id,
|
||||||
|
recorder_name=record.recorder.name if record.recorder else "Unknown",
|
||||||
|
created_at=record.created_at,
|
||||||
|
updated_at=record.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics", response_model=FundStatistics)
|
||||||
|
async def get_statistics(
|
||||||
|
class_id: int | None = None,
|
||||||
|
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
||||||
|
if effective_class_id is None:
|
||||||
|
return FundStatistics(
|
||||||
|
total_income=0, total_expense=0, balance=0,
|
||||||
|
income_by_category=[], expense_by_category=[]
|
||||||
|
)
|
||||||
|
return await get_fund_statistics(db, effective_class_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=PageResponse[FundRecordOut])
|
||||||
|
async def get_fund_records(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
type: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
class_id: int | None = None,
|
||||||
|
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
||||||
|
if effective_class_id is None:
|
||||||
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
|
||||||
|
records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category)
|
||||||
|
total_pages = (total + page_size - 1) // page_size
|
||||||
|
items = [record_to_out(r) for r in records]
|
||||||
|
return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=FundRecordOut)
|
||||||
|
async def create_new_record(
|
||||||
|
data: FundRecordCreate,
|
||||||
|
class_id: int | None = None,
|
||||||
|
user: User = Depends(require_role("super_admin", "class_admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
||||||
|
if effective_class_id is None:
|
||||||
|
raise HTTPException(status_code=400, detail="No class specified")
|
||||||
|
|
||||||
|
if data.type not in ("income", "expense"):
|
||||||
|
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
|
||||||
|
if data.amount <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Amount must be positive")
|
||||||
|
|
||||||
|
record = await create_fund_record(db, effective_class_id, user.id, data)
|
||||||
|
# Reload with recorder relationship
|
||||||
|
record = await get_fund_record_by_id(db, record.id)
|
||||||
|
return record_to_out(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{record_id}", response_model=FundRecordOut)
|
||||||
|
async def update_existing_record(
|
||||||
|
record_id: int,
|
||||||
|
data: FundRecordUpdate,
|
||||||
|
user: User = Depends(require_role("super_admin", "class_admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
record = await get_fund_record_by_id(db, record_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
|
if user.role != "super_admin" and record.class_id != user.class_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
if data.type is not None and data.type not in ("income", "expense"):
|
||||||
|
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
|
||||||
|
if data.amount is not None and data.amount <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Amount must be positive")
|
||||||
|
|
||||||
|
updated = await update_fund_record(db, record, data)
|
||||||
|
# Reload with recorder relationship
|
||||||
|
updated = await get_fund_record_by_id(db, updated.id)
|
||||||
|
return record_to_out(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{record_id}")
|
||||||
|
async def delete_existing_record(
|
||||||
|
record_id: int,
|
||||||
|
user: User = Depends(require_role("super_admin", "class_admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
record = await get_fund_record_by_id(db, record_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
|
if user.role != "super_admin" and record.class_id != user.class_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
await delete_fund_record(db, record)
|
||||||
|
return {"message": "Record deleted"}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func, UniqueConstraint
|
from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, Float, Date, func, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@ -22,7 +22,7 @@ class Class_(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# All available modules
|
# All available modules
|
||||||
ALL_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"]
|
ALL_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"]
|
||||||
|
|
||||||
def get_enabled_modules(self) -> list[str]:
|
def get_enabled_modules(self) -> list[str]:
|
||||||
if not self.enabled_modules:
|
if not self.enabled_modules:
|
||||||
@ -57,6 +57,9 @@ class Class_(Base):
|
|||||||
votes: Mapped[list["Vote"]] = relationship(
|
votes: Mapped[list["Vote"]] = relationship(
|
||||||
"Vote", back_populates="class_", cascade="all, delete-orphan"
|
"Vote", back_populates="class_", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
fund_records: Mapped[list["FundRecord"]] = relationship(
|
||||||
|
"FundRecord", back_populates="class_", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@ -430,3 +433,28 @@ class AssignmentSubmission(Base):
|
|||||||
|
|
||||||
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="submissions")
|
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="submissions")
|
||||||
student: Mapped["User"] = relationship("User", back_populates="assignment_submissions")
|
student: Mapped["User"] = relationship("User", back_populates="assignment_submissions")
|
||||||
|
|
||||||
|
|
||||||
|
class FundRecord(Base):
|
||||||
|
__tablename__ = "fund_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
class_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("classes.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# type: income | expense
|
||||||
|
type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
amount: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
category: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
record_date: Mapped[datetime] = mapped_column(Date, nullable=False)
|
||||||
|
recorder_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
class_: Mapped["Class_"] = relationship("Class_", back_populates="fund_records")
|
||||||
|
recorder: Mapped["User"] = relationship("User")
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.database import create_tables
|
from app.db.database import create_tables
|
||||||
from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments
|
from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||||
@ -88,7 +88,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[settings.frontend_url, "http://localhost:3000", "http://192.168.31.172:3000"],
|
allow_origins=[settings.frontend_url, "http://localhost:3001", "http://192.168.31.172:3001"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@ -106,6 +106,7 @@ app.include_router(resources.router)
|
|||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
app.include_router(votes.router)
|
app.include_router(votes.router)
|
||||||
app.include_router(assignments.router)
|
app.include_router(assignments.router)
|
||||||
|
app.include_router(fund.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
46
backend/app/schemas/fund.py
Normal file
46
backend/app/schemas/fund.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class FundRecordCreate(BaseModel):
|
||||||
|
type: str # income | expense
|
||||||
|
amount: float
|
||||||
|
category: str
|
||||||
|
description: str | None = None
|
||||||
|
record_date: date
|
||||||
|
|
||||||
|
|
||||||
|
class FundRecordUpdate(BaseModel):
|
||||||
|
type: str | None = None
|
||||||
|
amount: float | None = None
|
||||||
|
category: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
record_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FundRecordOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
class_id: int
|
||||||
|
type: str
|
||||||
|
amount: float
|
||||||
|
category: str
|
||||||
|
description: str | None
|
||||||
|
record_date: date
|
||||||
|
recorder_id: int
|
||||||
|
recorder_name: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryAmount(BaseModel):
|
||||||
|
category: str
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
|
class FundStatistics(BaseModel):
|
||||||
|
total_income: float
|
||||||
|
total_expense: float
|
||||||
|
balance: float
|
||||||
|
income_by_category: list[CategoryAmount]
|
||||||
|
expense_by_category: list[CategoryAmount]
|
||||||
127
backend/app/services/fund_service.py
Normal file
127
backend/app/services/fund_service.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.db.models import FundRecord, User
|
||||||
|
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundStatistics
|
||||||
|
|
||||||
|
|
||||||
|
async def create_fund_record(
|
||||||
|
db: AsyncSession, class_id: int, recorder_id: int, data: FundRecordCreate
|
||||||
|
) -> FundRecord:
|
||||||
|
record = FundRecord(
|
||||||
|
class_id=class_id,
|
||||||
|
recorder_id=recorder_id,
|
||||||
|
type=data.type,
|
||||||
|
amount=data.amount,
|
||||||
|
category=data.category,
|
||||||
|
description=data.description,
|
||||||
|
record_date=data.record_date,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def update_fund_record(
|
||||||
|
db: AsyncSession, record: FundRecord, data: FundRecordUpdate
|
||||||
|
) -> FundRecord:
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(record, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_fund_record(db: AsyncSession, record: FundRecord):
|
||||||
|
await db.delete(record)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fund_record_by_id(db: AsyncSession, record_id: int) -> FundRecord | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(FundRecord)
|
||||||
|
.options(selectinload(FundRecord.recorder))
|
||||||
|
.where(FundRecord.id == record_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_fund_records(
|
||||||
|
db: AsyncSession,
|
||||||
|
class_id: int,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
type: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
) -> tuple[list[FundRecord], int]:
|
||||||
|
query = select(FundRecord).where(FundRecord.class_id == class_id)
|
||||||
|
count_query = select(func.count(FundRecord.id)).where(FundRecord.class_id == class_id)
|
||||||
|
|
||||||
|
if type:
|
||||||
|
query = query.where(FundRecord.type == type)
|
||||||
|
count_query = count_query.where(FundRecord.type == type)
|
||||||
|
if category:
|
||||||
|
query = query.where(FundRecord.category == category)
|
||||||
|
count_query = count_query.where(FundRecord.category == category)
|
||||||
|
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
query.options(selectinload(FundRecord.recorder))
|
||||||
|
.order_by(FundRecord.record_date.desc(), FundRecord.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
records = list(result.scalars().all())
|
||||||
|
return records, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fund_statistics(db: AsyncSession, class_id: int) -> FundStatistics:
|
||||||
|
# Total income
|
||||||
|
income_result = await db.execute(
|
||||||
|
select(func.coalesce(func.sum(FundRecord.amount), 0))
|
||||||
|
.where(FundRecord.class_id == class_id, FundRecord.type == "income")
|
||||||
|
)
|
||||||
|
total_income = float(income_result.scalar() or 0)
|
||||||
|
|
||||||
|
# Total expense
|
||||||
|
expense_result = await db.execute(
|
||||||
|
select(func.coalesce(func.sum(FundRecord.amount), 0))
|
||||||
|
.where(FundRecord.class_id == class_id, FundRecord.type == "expense")
|
||||||
|
)
|
||||||
|
total_expense = float(expense_result.scalar() or 0)
|
||||||
|
|
||||||
|
balance = total_income - total_expense
|
||||||
|
|
||||||
|
# Income by category
|
||||||
|
income_cat_result = await db.execute(
|
||||||
|
select(FundRecord.category, func.sum(FundRecord.amount))
|
||||||
|
.where(FundRecord.class_id == class_id, FundRecord.type == "income")
|
||||||
|
.group_by(FundRecord.category)
|
||||||
|
)
|
||||||
|
income_by_category = [
|
||||||
|
{"category": row[0], "amount": float(row[1] or 0)}
|
||||||
|
for row in income_cat_result.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Expense by category
|
||||||
|
expense_cat_result = await db.execute(
|
||||||
|
select(FundRecord.category, func.sum(FundRecord.amount))
|
||||||
|
.where(FundRecord.class_id == class_id, FundRecord.type == "expense")
|
||||||
|
.group_by(FundRecord.category)
|
||||||
|
)
|
||||||
|
expense_by_category = [
|
||||||
|
{"category": row[0], "amount": float(row[1] or 0)}
|
||||||
|
for row in expense_cat_result.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
return FundStatistics(
|
||||||
|
total_income=total_income,
|
||||||
|
total_expense=total_expense,
|
||||||
|
balance=balance,
|
||||||
|
income_by_category=income_by_category,
|
||||||
|
expense_by_category=expense_by_category,
|
||||||
|
)
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, putAPI } from "@/lib/api";
|
import { fetchAPI, putAPI } from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@ -16,6 +17,7 @@ const ALL_MODULES = [
|
|||||||
{ key: "votes", label: "投票", desc: "发起班级投票活动" },
|
{ key: "votes", label: "投票", desc: "发起班级投票活动" },
|
||||||
{ key: "schedule", label: "排期表", desc: "查看课程和活动排期" },
|
{ key: "schedule", label: "排期表", desc: "查看课程和活动排期" },
|
||||||
{ key: "resources", label: "资源库", desc: "上传和下载学习资源" },
|
{ key: "resources", label: "资源库", desc: "上传和下载学习资源" },
|
||||||
|
{ key: "fund", label: "班费管理", desc: "记录和管理班费收支" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ModuleConfig {
|
interface ModuleConfig {
|
||||||
@ -25,7 +27,8 @@ interface ModuleConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ModulesPage() {
|
export default function ModulesPage() {
|
||||||
const { activeClassId, activeClassName } = useActiveClass();
|
const { activeClassId, activeClassName, refreshClasses } = useActiveClass();
|
||||||
|
const { refreshUser } = useAuth();
|
||||||
const [config, setConfig] = useState<ModuleConfig | null>(null);
|
const [config, setConfig] = useState<ModuleConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -65,6 +68,9 @@ export default function ModulesPage() {
|
|||||||
await putAPI(`/api/classes/${activeClassId}/modules`, {
|
await putAPI(`/api/classes/${activeClassId}/modules`, {
|
||||||
enabled_modules: updated,
|
enabled_modules: updated,
|
||||||
});
|
});
|
||||||
|
// Refresh user data and classes so enabled_modules updates in sidebar
|
||||||
|
await refreshUser();
|
||||||
|
await refreshClasses();
|
||||||
toast.success(
|
toast.success(
|
||||||
enabled
|
enabled
|
||||||
? `已启用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」`
|
? `已启用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」`
|
||||||
|
|||||||
451
frontend/src/app/(app)/fund/page.tsx
Normal file
451
frontend/src/app/(app)/fund/page.tsx
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { RoleGuard } from "@/components/role-guard";
|
||||||
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
|
import { ErrorState } from "@/components/error-state";
|
||||||
|
import { Pagination } from "@/components/pagination";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { FundRecord, FundStatistics } from "@/lib/types";
|
||||||
|
import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants";
|
||||||
|
|
||||||
|
export default function FundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { activeClassId } = useActiveClass();
|
||||||
|
const isAdmin = user?.role === "super_admin" || user?.role === "class_admin";
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
const [stats, setStats] = useState<FundStatistics | null>(null);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Records list
|
||||||
|
const [records, setRecords] = useState<FundRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [formType, setFormType] = useState<string>("income");
|
||||||
|
const [formAmount, setFormAmount] = useState("");
|
||||||
|
const [formCategory, setFormCategory] = useState("");
|
||||||
|
const [formDescription, setFormDescription] = useState("");
|
||||||
|
const [formDate, setFormDate] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
if (!activeClassId) { setStatsLoading(false); return; }
|
||||||
|
setStatsLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (user?.role === "super_admin" && activeClassId) {
|
||||||
|
params.class_id = String(activeClassId);
|
||||||
|
}
|
||||||
|
const res = await fetchAPI<FundStatistics>(
|
||||||
|
`/api/fund/statistics`,
|
||||||
|
Object.keys(params).length > 0 ? params : undefined
|
||||||
|
);
|
||||||
|
setStats(res);
|
||||||
|
} catch {
|
||||||
|
// ignore stats errors
|
||||||
|
} finally {
|
||||||
|
setStatsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRecords = async () => {
|
||||||
|
if (!activeClassId) { setLoading(false); return; }
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
page: String(page),
|
||||||
|
page_size: "20",
|
||||||
|
};
|
||||||
|
if (typeFilter !== "all") params.type = typeFilter;
|
||||||
|
if (user?.role === "super_admin") params.class_id = String(activeClassId);
|
||||||
|
|
||||||
|
const res = await fetchAPI<any>(`/api/fund/`, params);
|
||||||
|
setRecords(res.items || []);
|
||||||
|
setTotalPages(res.total_pages || 1);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeClassId) return;
|
||||||
|
loadStats();
|
||||||
|
loadRecords();
|
||||||
|
}, [activeClassId, page, typeFilter]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormType("income");
|
||||||
|
setFormAmount("");
|
||||||
|
setFormCategory("");
|
||||||
|
setFormDescription("");
|
||||||
|
setFormDate(new Date().toISOString().slice(0, 10));
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
resetForm();
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (record: FundRecord) => {
|
||||||
|
setEditingId(record.id);
|
||||||
|
setFormType(record.type);
|
||||||
|
setFormAmount(String(record.amount));
|
||||||
|
setFormCategory(record.category);
|
||||||
|
setFormDescription(record.description || "");
|
||||||
|
setFormDate(record.record_date);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!activeClassId) return;
|
||||||
|
const amount = parseFloat(formAmount);
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
toast.error("金额必须大于0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formCategory.trim()) {
|
||||||
|
toast.error("请填写分类");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formDate) {
|
||||||
|
toast.error("请选择日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
type: formType,
|
||||||
|
amount,
|
||||||
|
category: formCategory.trim(),
|
||||||
|
description: formDescription.trim() || null,
|
||||||
|
record_date: formDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
await putAPI(`/api/fund/${editingId}`, payload);
|
||||||
|
toast.success("记录已更新");
|
||||||
|
} else {
|
||||||
|
const url = user?.role === "super_admin" && activeClassId
|
||||||
|
? `/api/fund/?class_id=${activeClassId}`
|
||||||
|
: `/api/fund/`;
|
||||||
|
await postAPI(url, payload);
|
||||||
|
toast.success("记录已添加");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
loadStats();
|
||||||
|
loadRecords();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "操作失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteAPI(`/api/fund/${deleteTarget}`);
|
||||||
|
toast.success("记录已删除");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
loadStats();
|
||||||
|
loadRecords();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categories = formType === "income" ? FUND_INCOME_CATEGORIES : FUND_EXPENSE_CATEGORIES;
|
||||||
|
|
||||||
|
if (!activeClassId) {
|
||||||
|
return <div className="text-center py-12 text-gray-400">请先选择一个班级</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">班费管理</h1>
|
||||||
|
<p className="text-gray-500 mt-1">记录和管理班费收支</p>
|
||||||
|
</div>
|
||||||
|
<RoleGuard roles={["super_admin", "class_admin"]}>
|
||||||
|
<Button onClick={openCreate}>添加记录</Button>
|
||||||
|
</RoleGuard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-sm text-gray-500">总收入</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
¥{statsLoading ? "—" : (stats?.total_income ?? 0).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-sm text-gray-500">总支出</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">
|
||||||
|
¥{statsLoading ? "—" : (stats?.total_expense ?? 0).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-sm text-gray-500">余额</p>
|
||||||
|
<p className={`text-2xl font-bold ${(stats?.balance ?? 0) >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
¥{statsLoading ? "—" : (stats?.balance ?? 0).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category breakdown */}
|
||||||
|
{stats && (stats.income_by_category.length > 0 || stats.expense_by_category.length > 0) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">分类统计</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-2 font-medium">收入分类</p>
|
||||||
|
{stats.income_by_category.map((item) => (
|
||||||
|
<div key={item.category} className="flex justify-between text-sm py-1">
|
||||||
|
<span>{item.category}</span>
|
||||||
|
<span className="text-green-600">¥{item.amount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stats.income_by_category.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">暂无收入记录</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-2 font-medium">支出分类</p>
|
||||||
|
{stats.expense_by_category.map((item) => (
|
||||||
|
<div key={item.category} className="flex justify-between text-sm py-1">
|
||||||
|
<span>{item.category}</span>
|
||||||
|
<span className="text-red-600">¥{item.amount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stats.expense_by_category.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">暂无支出记录</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={typeFilter === "all" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTypeFilter("all")}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={typeFilter === "income" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTypeFilter("income")}
|
||||||
|
>
|
||||||
|
入账
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={typeFilter === "expense" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTypeFilter("expense")}
|
||||||
|
>
|
||||||
|
出账
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Records list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="h-12 bg-gray-200 rounded" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<ErrorState message={error} onRetry={loadRecords} />
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
{typeFilter === "all" ? "暂无班费记录" : `暂无${FUND_TYPES[typeFilter as keyof typeof FUND_TYPES]}记录`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{records.map((r) => (
|
||||||
|
<Card key={r.id}>
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
className={r.type === "income"
|
||||||
|
? "bg-green-100 text-green-800 hover:bg-green-100 border-green-200"
|
||||||
|
: "bg-red-100 text-red-800 hover:bg-red-100 border-red-200"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{FUND_TYPES[r.type as keyof typeof FUND_TYPES]}
|
||||||
|
</Badge>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
<span className={r.type === "income" ? "text-green-600" : "text-red-600"}>
|
||||||
|
{r.type === "income" ? "+" : "-"}¥{r.amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-gray-600">{r.category}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{r.record_date} · {r.recorder_name}
|
||||||
|
{r.description ? ` · ${r.description}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500"
|
||||||
|
onClick={() => setDeleteTarget(r.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? "编辑记录" : "添加记录"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div>
|
||||||
|
<Label>类型</Label>
|
||||||
|
<Select value={formType} onValueChange={(v) => { setFormType(v); setFormCategory(""); }}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue>{FUND_TYPES[formType as keyof typeof FUND_TYPES]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="income">入账</SelectItem>
|
||||||
|
<SelectItem value="expense">出账</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>金额(元)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={formAmount}
|
||||||
|
onChange={(e) => setFormAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>分类</Label>
|
||||||
|
<Select value={formCategory} onValueChange={setFormCategory}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="选择分类">{formCategory || "选择分类"}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>描述</Label>
|
||||||
|
<Textarea
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="备注说明(可选)"
|
||||||
|
value={formDescription}
|
||||||
|
onChange={(e) => setFormDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>日期</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="mt-1"
|
||||||
|
value={formDate}
|
||||||
|
onChange={(e) => setFormDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? "提交中..." : (editingId ? "保存修改" : "添加记录")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
|
||||||
|
title="删除班费记录"
|
||||||
|
description="确定删除该班费记录?此操作不可恢复。"
|
||||||
|
confirmText="删除"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,7 +9,7 @@ import type { UserRole } from "@/lib/types";
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
// Module keys that can be toggled
|
// Module keys that can be toggled
|
||||||
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"];
|
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"];
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined },
|
{ href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined },
|
||||||
@ -20,6 +20,7 @@ const navItems = [
|
|||||||
{ href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" },
|
{ href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" },
|
||||||
{ href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", moduleKey: "schedule" },
|
{ href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", moduleKey: "schedule" },
|
||||||
{ href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z", moduleKey: "resources" },
|
{ href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z", moduleKey: "resources" },
|
||||||
|
{ href: "/fund", label: "班费管理", icon: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "fund" },
|
||||||
{ href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", moduleKey: undefined },
|
{ href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", moduleKey: undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ export function Sidebar() {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Default to all modules enabled if not loaded yet
|
// Default to all modules enabled if not loaded yet
|
||||||
const defaultModules = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"];
|
const defaultModules = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"];
|
||||||
const enabledSet = new Set(enabledModules ?? defaultModules);
|
const enabledSet = new Set(enabledModules ?? defaultModules);
|
||||||
|
|
||||||
// Filter navItems based on enabled modules (items without moduleKey are always visible)
|
// Filter navItems based on enabled modules (items without moduleKey are always visible)
|
||||||
|
|||||||
@ -25,6 +25,8 @@ interface ActiveClassContextValue {
|
|||||||
setActiveClassId: (id: number) => void;
|
setActiveClassId: (id: number) => void;
|
||||||
/** Enabled modules for the active class */
|
/** Enabled modules for the active class */
|
||||||
enabledModules: string[] | null;
|
enabledModules: string[] | null;
|
||||||
|
/** Refresh available classes from API */
|
||||||
|
refreshClasses: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActiveClassContext = createContext<ActiveClassContextValue>({
|
const ActiveClassContext = createContext<ActiveClassContextValue>({
|
||||||
@ -34,6 +36,7 @@ const ActiveClassContext = createContext<ActiveClassContextValue>({
|
|||||||
availableClasses: [],
|
availableClasses: [],
|
||||||
setActiveClassId: () => {},
|
setActiveClassId: () => {},
|
||||||
enabledModules: null,
|
enabledModules: null,
|
||||||
|
refreshClasses: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
||||||
@ -97,6 +100,16 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem("active_class_id", String(id));
|
localStorage.setItem("active_class_id", String(id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshClasses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchAPI<any>("/api/classes/");
|
||||||
|
const items = res.items || [];
|
||||||
|
setAvailableClasses(items);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActiveClassContext.Provider
|
<ActiveClassContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -106,6 +119,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
|||||||
availableClasses,
|
availableClasses,
|
||||||
setActiveClassId,
|
setActiveClassId,
|
||||||
enabledModules,
|
enabledModules,
|
||||||
|
refreshClasses,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -41,3 +41,23 @@ export const COMMITTEE_ROLES = [
|
|||||||
"文体委员",
|
"文体委员",
|
||||||
"生活委员",
|
"生活委员",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const FUND_TYPES = {
|
||||||
|
income: "入账",
|
||||||
|
expense: "出账",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const FUND_INCOME_CATEGORIES = [
|
||||||
|
"班费收取",
|
||||||
|
"活动赞助",
|
||||||
|
"其他收入",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const FUND_EXPENSE_CATEGORIES = [
|
||||||
|
"聚餐",
|
||||||
|
"活动物资",
|
||||||
|
"场地费",
|
||||||
|
"交通费",
|
||||||
|
"礼品",
|
||||||
|
"其他支出",
|
||||||
|
] as const;
|
||||||
|
|||||||
@ -220,3 +220,25 @@ export interface Assignment {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
submissions?: AssignmentSubmission[];
|
submissions?: AssignmentSubmission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FundRecord {
|
||||||
|
id: number;
|
||||||
|
class_id: number;
|
||||||
|
type: "income" | "expense";
|
||||||
|
amount: number;
|
||||||
|
category: string;
|
||||||
|
description: string | null;
|
||||||
|
record_date: string;
|
||||||
|
recorder_id: number;
|
||||||
|
recorder_name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FundStatistics {
|
||||||
|
total_income: number;
|
||||||
|
total_expense: number;
|
||||||
|
balance: number;
|
||||||
|
income_by_category: { category: string; amount: number }[];
|
||||||
|
expense_by_category: { category: string; amount: number }[];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user