first commit
This commit is contained in:
286
app/api/client.ts
Normal file
286
app/api/client.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type {
|
||||
AuthResponse,
|
||||
UserLogin,
|
||||
UserRegister,
|
||||
User,
|
||||
Subject,
|
||||
SubjectCreate,
|
||||
SubjectUpdate,
|
||||
Grade,
|
||||
GradeCreate,
|
||||
GradeUpdate,
|
||||
ReportPeriod,
|
||||
ReportPeriodCreate,
|
||||
ReportPeriodUpdate,
|
||||
TeacherGrade,
|
||||
TeacherGradeCreate,
|
||||
TeacherGradeUpdate,
|
||||
} from "~/types/api";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api";
|
||||
|
||||
class ApiClient {
|
||||
private getHeaders(includeAuth: boolean = false): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (includeAuth) {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "An error occurred" }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async register(data: UserRegister): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await this.handleResponse<AuthResponse>(response);
|
||||
localStorage.setItem("access_token", result.access_token);
|
||||
return result;
|
||||
}
|
||||
|
||||
async login(data: UserLogin): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await this.handleResponse<AuthResponse>(response);
|
||||
localStorage.setItem("access_token", result.access_token);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<User>(response);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem("access_token");
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!localStorage.getItem("access_token");
|
||||
}
|
||||
|
||||
// Subject endpoints
|
||||
async getSubjects(): Promise<Subject[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/subjects`, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<Subject[]>(response);
|
||||
}
|
||||
|
||||
async getSubject(id: string): Promise<Subject> {
|
||||
const response = await fetch(`${API_BASE_URL}/subjects/${id}`, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<Subject>(response);
|
||||
}
|
||||
|
||||
async createSubject(data: SubjectCreate): Promise<Subject> {
|
||||
const response = await fetch(`${API_BASE_URL}/subjects`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(true),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<Subject>(response);
|
||||
}
|
||||
|
||||
async updateSubject(id: string, data: SubjectUpdate): Promise<Subject> {
|
||||
const response = await fetch(`${API_BASE_URL}/subjects/${id}`, {
|
||||
method: "PUT",
|
||||
headers: this.getHeaders(true),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<Subject>(response);
|
||||
}
|
||||
|
||||
async deleteSubject(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/subjects/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<void>(response);
|
||||
}
|
||||
|
||||
// Grade endpoints
|
||||
async getGrades(subjectId?: string): Promise<Grade[]> {
|
||||
const url = subjectId
|
||||
? `${API_BASE_URL}/grades?subject_id=${subjectId}`
|
||||
: `${API_BASE_URL}/grades`;
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<Grade[]>(response);
|
||||
}
|
||||
|
||||
async getGrade(id: string): Promise<Grade> {
|
||||
const response = await fetch(`${API_BASE_URL}/grades/${id}`, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<Grade>(response);
|
||||
}
|
||||
|
||||
async createGrade(data: GradeCreate): Promise<Grade> {
|
||||
const response = await fetch(`${API_BASE_URL}/grades`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(true),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<Grade>(response);
|
||||
}
|
||||
|
||||
async updateGrade(id: string, data: GradeUpdate): Promise<Grade> {
|
||||
const response = await fetch(`${API_BASE_URL}/grades/${id}`, {
|
||||
method: "PUT",
|
||||
headers: this.getHeaders(true),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<Grade>(response);
|
||||
}
|
||||
|
||||
async deleteGrade(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/grades/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<void>(response);
|
||||
}
|
||||
|
||||
// Report Period endpoints
|
||||
async getPeriods(): Promise<ReportPeriod[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/periods`, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<ReportPeriod[]>(response);
|
||||
}
|
||||
|
||||
async getPeriod(id: string): Promise<ReportPeriod> {
|
||||
const response = await fetch(`${API_BASE_URL}/periods/${id}`, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<ReportPeriod>(response);
|
||||
}
|
||||
|
||||
async createPeriod(data: ReportPeriodCreate): Promise<ReportPeriod> {
|
||||
const response = await fetch(`${API_BASE_URL}/periods`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(true),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<ReportPeriod>(response);
|
||||
}
|
||||
|
||||
async updatePeriod(id: string, data: ReportPeriodUpdate): Promise<ReportPeriod> {
|
||||
const response = await fetch(`${API_BASE_URL}/periods/${id}`, {
|
||||
method: "PUT",
|
||||
headers: this.getHeaders(true),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<ReportPeriod>(response);
|
||||
}
|
||||
|
||||
async deletePeriod(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/periods/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<void>(response);
|
||||
}
|
||||
|
||||
// Teacher Grade endpoints
|
||||
async getTeacherGrades(subjectId?: string, periodId?: string): Promise<TeacherGrade[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (subjectId) params.append("subject_id", subjectId);
|
||||
if (periodId) params.append("period_id", periodId);
|
||||
const url = `${API_BASE_URL}/teacher-grades${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<TeacherGrade[]>(response);
|
||||
}
|
||||
|
||||
async getTeacherGrade(id: string): Promise<TeacherGrade> {
|
||||
const response = await fetch(`${API_BASE_URL}/teacher-grades/${id}`, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<TeacherGrade>(response);
|
||||
}
|
||||
|
||||
async createTeacherGrade(data: TeacherGradeCreate): Promise<TeacherGrade> {
|
||||
const response = await fetch(`${API_BASE_URL}/teacher-grades`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(true),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<TeacherGrade>(response);
|
||||
}
|
||||
|
||||
async deleteTeacherGrade(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/teacher-grades/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
return this.handleResponse<void>(response);
|
||||
}
|
||||
|
||||
// Export endpoints
|
||||
async exportGradesCSV(periodId?: string): Promise<void> {
|
||||
const url = periodId
|
||||
? `${API_BASE_URL}/export/grades?period_id=${periodId}`
|
||||
: `${API_BASE_URL}/export/grades`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders(true),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to export grades");
|
||||
}
|
||||
|
||||
// Create a blob from the response and trigger download
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadUrl;
|
||||
|
||||
// Extract filename from Content-Disposition header or use default
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
const filename = contentDisposition
|
||||
? contentDisposition.split("filename=")[1]?.replace(/"/g, "")
|
||||
: "grades_export.csv";
|
||||
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
1
app/app.css
Normal file
1
app/app.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
40
app/components/Button.tsx
Normal file
40
app/components/Button.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
const variants = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800",
|
||||
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 active:bg-gray-400",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800",
|
||||
ghost: "text-gray-700 hover:bg-gray-100 active:bg-gray-200",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
221
app/components/GradeCalculator.tsx
Normal file
221
app/components/GradeCalculator.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState } from "react";
|
||||
import type { Subject, Grade } from "~/types/api";
|
||||
import { calculateRequiredGrade, type RequiredGradeResult } from "~/utils/gradeCalculations";
|
||||
import { Button } from "./Button";
|
||||
import { Input } from "./Input";
|
||||
|
||||
interface GradeCalculatorProps {
|
||||
subject: Subject;
|
||||
currentGrades: Grade[];
|
||||
}
|
||||
|
||||
export function GradeCalculator({ subject, currentGrades }: GradeCalculatorProps) {
|
||||
const [targetGrade, setTargetGrade] = useState(
|
||||
subject.target_grade?.toString() || ""
|
||||
);
|
||||
const [maxGradeValue, setMaxGradeValue] = useState(
|
||||
subject.grade_system === "german" ? "6" : "100"
|
||||
);
|
||||
const [remainingAssignments, setRemainingAssignments] = useState<{
|
||||
[key: string]: string;
|
||||
}>(() => {
|
||||
const initial: { [key: string]: string } = {};
|
||||
subject.grading_categories.forEach((cat) => {
|
||||
initial[cat.name] = "1";
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const [results, setResults] = useState<RequiredGradeResult[] | null>(null);
|
||||
const [showCalculator, setShowCalculator] = useState(false);
|
||||
|
||||
const handleCalculate = () => {
|
||||
if (!targetGrade || parseFloat(targetGrade) <= 0) {
|
||||
alert("Please enter a valid target grade");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxGrade = parseFloat(maxGradeValue);
|
||||
const target = parseFloat(targetGrade);
|
||||
|
||||
// Convert target to percentage
|
||||
let targetPercentage: number;
|
||||
if (subject.grade_system === "german" && maxGrade === 6) {
|
||||
// For German grades, convert using rough approximation
|
||||
if (target <= 1.5) targetPercentage = 92;
|
||||
else if (target <= 2.5) targetPercentage = 81;
|
||||
else if (target <= 3.5) targetPercentage = 67;
|
||||
else if (target <= 4.0) targetPercentage = 50;
|
||||
else if (target <= 5.0) targetPercentage = 30;
|
||||
else targetPercentage = 0;
|
||||
} else {
|
||||
targetPercentage = (target / maxGrade) * 100;
|
||||
}
|
||||
|
||||
// Convert remaining assignments to numbers
|
||||
const remaining: { [key: string]: number } = {};
|
||||
Object.keys(remainingAssignments).forEach((key) => {
|
||||
remaining[key] = parseInt(remainingAssignments[key]) || 0;
|
||||
});
|
||||
|
||||
const calculationResults = calculateRequiredGrade(
|
||||
subject,
|
||||
currentGrades,
|
||||
targetPercentage,
|
||||
remaining,
|
||||
maxGrade
|
||||
);
|
||||
|
||||
setResults(calculationResults);
|
||||
};
|
||||
|
||||
if (!showCalculator) {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900">
|
||||
🎯 What-If Grade Calculator
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Calculate what grades you need on remaining assignments to reach your target
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCalculator(true)} variant="secondary">
|
||||
Open Calculator
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
🎯 What-If Grade Calculator
|
||||
</h3>
|
||||
<Button onClick={() => setShowCalculator(false)} variant="ghost">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Target Grade
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={targetGrade}
|
||||
onChange={(e) => setTargetGrade(e.target.value)}
|
||||
placeholder={subject.grade_system === "german" ? "2.0" : "85"}
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Maximum Grade Value
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxGradeValue}
|
||||
onChange={(e) => setMaxGradeValue(e.target.value)}
|
||||
placeholder={subject.grade_system === "german" ? "6" : "100"}
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Remaining Assignments per Category
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{subject.grading_categories.map((category) => (
|
||||
<div key={category.name} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-700 flex-1">
|
||||
{category.name} ({category.weight}%)
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={remainingAssignments[category.name] || "0"}
|
||||
onChange={(e) =>
|
||||
setRemainingAssignments({
|
||||
...remainingAssignments,
|
||||
[category.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
min="0"
|
||||
step="1"
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCalculate} className="w-full">
|
||||
Calculate Required Grades
|
||||
</Button>
|
||||
|
||||
{results && (
|
||||
<div className="border-t pt-6 space-y-4">
|
||||
<h4 className="font-semibold text-gray-900">Results:</h4>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={result.categoryName}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
!result.isPossible
|
||||
? "bg-red-50 border-red-500"
|
||||
: result.requiredPercentage > 90
|
||||
? "bg-yellow-50 border-yellow-500"
|
||||
: "bg-green-50 border-green-500"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h5 className="font-semibold text-gray-900">
|
||||
{result.categoryName}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600">
|
||||
Weight: {result.categoryWeight}% of final grade
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{result.requiredGrade.toFixed(2)} / {result.maxGrade}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
({result.requiredPercentage.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
!result.isPossible
|
||||
? "text-red-700 font-semibold"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{result.message}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Note:</strong> These calculations assume each remaining
|
||||
assignment has equal weight (weight = 1.0) within its category. If
|
||||
your assignments have different weights, adjust accordingly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
app/components/GradeTable.tsx
Normal file
149
app/components/GradeTable.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Grade, Subject } from "~/types/api";
|
||||
import { formatGrade } from "~/utils/gradeCalculations";
|
||||
|
||||
interface GradeTableProps {
|
||||
grades: Grade[];
|
||||
subject?: Subject;
|
||||
onEdit?: (grade: Grade) => void;
|
||||
onDelete?: (gradeId: string) => void;
|
||||
deletingGradeId?: string | null;
|
||||
}
|
||||
|
||||
export function GradeTable({ grades, subject, onEdit, onDelete, deletingGradeId }: GradeTableProps) {
|
||||
if (grades.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No grades recorded yet. Add your first grade!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Date</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Name</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Category</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">Grade</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">Percentage</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">Weight</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grades.map((grade) => {
|
||||
const percentage = (grade.grade / grade.max_grade) * 100;
|
||||
const category = subject?.grading_categories.find(
|
||||
(c) => c.name === grade.category_name
|
||||
);
|
||||
|
||||
return (
|
||||
<tr key={grade._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{new Date(grade.date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium">{grade.name || "Unnamed"}</div>
|
||||
{grade.notes && (
|
||||
<div className="text-sm text-gray-500">{grade.notes}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className="px-2 py-1 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: category?.color || "#e5e7eb",
|
||||
color: "#1f2937",
|
||||
}}
|
||||
>
|
||||
{grade.category_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right font-medium">
|
||||
{grade.grade} / {grade.max_grade}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right font-semibold">
|
||||
{formatGrade(percentage)}%
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-sm text-gray-600">
|
||||
{grade.weight_in_category}x
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(grade)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
aria-label="Edit grade"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(grade._id)}
|
||||
className="text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
aria-label="Delete grade"
|
||||
disabled={deletingGradeId === grade._id}
|
||||
>
|
||||
{deletingGradeId === grade._id ? (
|
||||
<svg
|
||||
className="w-4 h-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
app/components/GradingCategorySelector.tsx
Normal file
142
app/components/GradingCategorySelector.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import type { GradingCategory } from "~/types/api";
|
||||
import { Button } from "./Button";
|
||||
import { Input } from "./Input";
|
||||
|
||||
interface GradingCategorySelectorProps {
|
||||
categories: GradingCategory[];
|
||||
onChange: (categories: GradingCategory[]) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
"#10b981", // green
|
||||
"#f59e0b", // amber
|
||||
"#ef4444", // red
|
||||
"#8b5cf6", // purple
|
||||
"#ec4899", // pink
|
||||
];
|
||||
|
||||
export function GradingCategorySelector({
|
||||
categories,
|
||||
onChange,
|
||||
}: GradingCategorySelectorProps) {
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const addCategory = () => {
|
||||
const newCategory: GradingCategory = {
|
||||
name: "",
|
||||
weight: 0,
|
||||
color: DEFAULT_COLORS[categories.length % DEFAULT_COLORS.length],
|
||||
};
|
||||
onChange([...categories, newCategory]);
|
||||
};
|
||||
|
||||
const updateCategory = (index: number, field: keyof GradingCategory, value: any) => {
|
||||
const updated = [...categories];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
onChange(updated);
|
||||
validateWeights(updated);
|
||||
};
|
||||
|
||||
const removeCategory = (index: number) => {
|
||||
const updated = categories.filter((_, i) => i !== index);
|
||||
onChange(updated);
|
||||
validateWeights(updated);
|
||||
};
|
||||
|
||||
const validateWeights = (cats: GradingCategory[]) => {
|
||||
const total = cats.reduce((sum, cat) => sum + (Number(cat.weight) || 0), 0);
|
||||
if (Math.abs(total - 100) > 0.01 && total > 0) {
|
||||
setError(`Total weight: ${total.toFixed(1)}% (must equal 100%)`);
|
||||
} else {
|
||||
setError("");
|
||||
}
|
||||
};
|
||||
|
||||
const totalWeight = categories.reduce((sum, cat) => sum + (Number(cat.weight) || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Grading Categories
|
||||
</label>
|
||||
<Button type="button" size="sm" onClick={addCategory}>
|
||||
+ Add Category
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{categories.map((category, index) => (
|
||||
<div key={index} className="flex gap-3 items-start p-3 border rounded-lg">
|
||||
<Input
|
||||
placeholder="Category name (e.g., Written, Oral)"
|
||||
value={category.name}
|
||||
onChange={(e) => updateCategory(index, "name", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Weight %"
|
||||
value={category.weight || ""}
|
||||
onChange={(e) =>
|
||||
updateCategory(index, "weight", parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="w-24"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={category.color || "#3b82f6"}
|
||||
onChange={(e) => updateCategory(index, "color", e.target.value)}
|
||||
className="w-12 h-10 rounded cursor-pointer"
|
||||
title="Category color"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCategory(index)}
|
||||
className="text-red-500 hover:text-red-700 p-2"
|
||||
aria-label="Remove category"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{categories.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
Add at least one grading category
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium text-gray-700">Total Weight:</span>
|
||||
<span
|
||||
className={`font-bold ${
|
||||
Math.abs(totalWeight - 100) < 0.01 ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{totalWeight.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/components/Input.tsx
Normal file
25
app/components/Input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = "", ...props }: InputProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
error ? "border-red-500" : "border-gray-300"
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/components/LoadingSpinner.tsx
Normal file
19
app/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = "md" }: LoadingSpinnerProps) {
|
||||
const sizes = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-8 h-8",
|
||||
lg: "w-12 h-12",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<div
|
||||
className={`${sizes[size]} border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
app/components/ProtectedRoute.tsx
Normal file
23
app/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { api } from "~/api/client";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api.isAuthenticated()) {
|
||||
navigate("/login");
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
if (!api.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
30
app/components/StatCard.tsx
Normal file
30
app/components/StatCard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, subtitle, color = "#3b82f6", icon }: StatCardProps) {
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg border-2 border-gray-200 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600 mb-1">{title}</p>
|
||||
<p className="text-3xl font-bold" style={{ color }}>
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="p-3 rounded-lg" style={{ backgroundColor: `${color}20` }}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
app/components/SubjectCard.tsx
Normal file
114
app/components/SubjectCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Subject } from "~/types/api";
|
||||
import { Link } from "react-router";
|
||||
import { formatGradeBySystem, type GradeSystem } from "~/utils/gradeSystems";
|
||||
import { calculateTargetProgress, getTargetStatusBgColor } from "~/utils/gradeCalculations";
|
||||
|
||||
interface SubjectCardProps {
|
||||
subject: Subject;
|
||||
averageGrade?: number;
|
||||
calculatedAverage?: number;
|
||||
gradeSystem?: string;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function SubjectCard({ subject, averageGrade, calculatedAverage, gradeSystem, onDelete }: SubjectCardProps) {
|
||||
// Use provided gradeSystem, fallback to subject's own system
|
||||
const displaySystem = (gradeSystem || subject.grade_system || "percentage") as GradeSystem;
|
||||
|
||||
// Calculate target progress if grade is available
|
||||
const targetProgress = averageGrade !== undefined ? calculateTargetProgress(subject.target_grade ?? averageGrade, subject) : null;
|
||||
const hasTaget = targetProgress && targetProgress.status !== "no-target";
|
||||
|
||||
const isOverridden = calculatedAverage !== undefined && averageGrade !== undefined && Math.abs(calculatedAverage - averageGrade) > 0.01;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-lg border-2 hover:shadow-lg transition-shadow ${hasTaget ? getTargetStatusBgColor(targetProgress.status) : ""}`}
|
||||
style={{ borderColor: subject.color || "#3b82f6" }}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<Link to={`/subjects/${subject._id}`} className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-900 hover:text-blue-600">
|
||||
{subject.name}
|
||||
</h3>
|
||||
{subject.teacher && (
|
||||
<p className="text-sm text-gray-600 mt-1">{subject.teacher}</p>
|
||||
)}
|
||||
</Link>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-red-500 hover:text-red-700 ml-2"
|
||||
aria-label="Delete subject"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{averageGrade !== undefined && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-3xl font-bold" style={{ color: subject.color || "#3b82f6" }}>
|
||||
{formatGradeBySystem(averageGrade, displaySystem, 1)}
|
||||
{isOverridden && (
|
||||
<span className="text-gray-500 text-lg font-normal ml-2">
|
||||
({formatGradeBySystem(calculatedAverage!, displaySystem, 1)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Current Average</p>
|
||||
</div>
|
||||
{hasTaget && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">Target: {formatGradeBySystem(targetProgress.targetPercentage, displaySystem, 1)}</div>
|
||||
<div className="text-xs font-semibold mt-1">
|
||||
{targetProgress.status === "above" && (
|
||||
<span className="text-green-700">✓ Above Target</span>
|
||||
)}
|
||||
{targetProgress.status === "near" && (
|
||||
<span className="text-yellow-700">→ Near Target</span>
|
||||
)}
|
||||
{targetProgress.status === "below" && (
|
||||
<span className="text-red-700">↓ Below Target</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">Grading Categories:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subject.grading_categories.map((category, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: category.color || "#e5e7eb",
|
||||
color: "#1f2937",
|
||||
}}
|
||||
>
|
||||
{category.name} ({category.weight}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
app/root.tsx
Normal file
75
app/root.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details =
|
||||
error.status === 404
|
||||
? "The requested page could not be found."
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
13
app/routes.ts
Normal file
13
app/routes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
index("routes/home.tsx"),
|
||||
route("login", "routes/login.tsx"),
|
||||
route("register", "routes/register.tsx"),
|
||||
route("dashboard", "routes/dashboard.tsx"),
|
||||
route("subjects", "routes/subjects.tsx"),
|
||||
route("subjects/new", "routes/subjects.new.tsx"),
|
||||
route("subjects/:subjectId", "routes/subjects.$subjectId.tsx"),
|
||||
route("subjects/:subjectId/edit", "routes/subjects.$subjectId.edit.tsx"),
|
||||
route("periods", "routes/periods.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
629
app/routes/dashboard.tsx
Normal file
629
app/routes/dashboard.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ProtectedRoute } from "~/components/ProtectedRoute";
|
||||
import { StatCard } from "~/components/StatCard";
|
||||
import { SubjectCard } from "~/components/SubjectCard";
|
||||
import { LoadingSpinner } from "~/components/LoadingSpinner";
|
||||
import { Button } from "~/components/Button";
|
||||
import { api } from "~/api/client";
|
||||
import type { Subject, Grade, ReportPeriod, User, TeacherGrade } from "~/types/api";
|
||||
import { calculateReportCard, calculateOverallGPA, calculateTargetProgress, getTargetStatusColor } from "~/utils/gradeCalculations";
|
||||
import { formatGradeBySystem, germanGradeToPercentage, type GradeSystem } from "~/utils/gradeSystems";
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [grades, setGrades] = useState<Grade[]>([]);
|
||||
const [periods, setPeriods] = useState<ReportPeriod[]>([]);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<ReportPeriod | null>(null);
|
||||
const [teacherGrades, setTeacherGrades] = useState<TeacherGrade[]>([]);
|
||||
const [editingTeacherGrade, setEditingTeacherGrade] = useState<{ subjectId: string; value: string; maxValue: string; notes: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [deletingTeacherGradeId, setDeletingTeacherGradeId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [userData, subjectsData, gradesData, periodsData, teacherGradesData] = await Promise.all([
|
||||
api.getMe(),
|
||||
api.getSubjects(),
|
||||
api.getGrades(),
|
||||
api.getPeriods(),
|
||||
api.getTeacherGrades(),
|
||||
]);
|
||||
|
||||
setUser(userData);
|
||||
setSubjects(subjectsData);
|
||||
setGrades(gradesData);
|
||||
setPeriods(periodsData);
|
||||
setTeacherGrades(teacherGradesData);
|
||||
|
||||
if (periodsData.length > 0) {
|
||||
// Find the period that contains today's date
|
||||
const today = new Date();
|
||||
const currentPeriod = periodsData.find(period => {
|
||||
const start = new Date(period.start_date);
|
||||
const end = new Date(period.end_date);
|
||||
return today >= start && today <= end;
|
||||
});
|
||||
|
||||
// Use current period if found, otherwise use the most recent period (first in list)
|
||||
setSelectedPeriod(currentPeriod || periodsData[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
api.logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const handleTeacherGradeClick = (subjectId: string) => {
|
||||
const subject = subjects.find(s => s._id === subjectId);
|
||||
if (!subject || !selectedPeriod) return;
|
||||
|
||||
const existing = teacherGrades.find(
|
||||
tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id
|
||||
);
|
||||
|
||||
const config = getGradeInputConfig(subject.grade_system as GradeSystem);
|
||||
|
||||
setEditingTeacherGrade({
|
||||
subjectId,
|
||||
value: existing ? existing.grade.toString() : "",
|
||||
maxValue: existing ? existing.max_grade.toString() : config.maxGradeValue,
|
||||
notes: existing?.notes || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveTeacherGrade = async () => {
|
||||
if (!editingTeacherGrade || !selectedPeriod) return;
|
||||
|
||||
try {
|
||||
const subject = subjects.find(s => s._id === editingTeacherGrade.subjectId);
|
||||
if (!subject) return;
|
||||
|
||||
const config = getGradeInputConfig(subject.grade_system as GradeSystem);
|
||||
const finalMaxGrade = config.showMaxGradeInput
|
||||
? parseFloat(editingTeacherGrade.maxValue)
|
||||
: parseFloat(config.maxGradeValue);
|
||||
|
||||
const data = {
|
||||
subject_id: editingTeacherGrade.subjectId,
|
||||
period_id: selectedPeriod._id,
|
||||
grade: parseFloat(editingTeacherGrade.value),
|
||||
max_grade: finalMaxGrade,
|
||||
notes: editingTeacherGrade.notes || undefined,
|
||||
};
|
||||
|
||||
const result = await api.createTeacherGrade(data);
|
||||
setTeacherGrades([...teacherGrades.filter(
|
||||
tg => !(tg.subject_id === result.subject_id && tg.period_id === result.period_id)
|
||||
), result]);
|
||||
setEditingTeacherGrade(null);
|
||||
} catch (error) {
|
||||
alert("Failed to save teacher grade");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeacherGrade = async (subjectId: string) => {
|
||||
if (!selectedPeriod) return;
|
||||
|
||||
const existing = teacherGrades.find(
|
||||
tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id
|
||||
);
|
||||
|
||||
if (existing && confirm("Remove teacher's grade?")) {
|
||||
try {
|
||||
setDeletingTeacherGradeId(subjectId);
|
||||
await api.deleteTeacherGrade(existing._id);
|
||||
setTeacherGrades(teacherGrades.filter(tg => tg._id !== existing._id));
|
||||
} catch (error) {
|
||||
alert("Failed to delete teacher grade");
|
||||
} finally {
|
||||
setDeletingTeacherGradeId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getGradeInputConfig = (gradeSystem: GradeSystem) => {
|
||||
switch (gradeSystem) {
|
||||
case "german":
|
||||
return {
|
||||
label: "German Grade",
|
||||
placeholder: "1.0",
|
||||
min: "1",
|
||||
max: "6",
|
||||
step: "0.1",
|
||||
maxGradeValue: "6",
|
||||
showMaxGradeInput: false,
|
||||
};
|
||||
case "us-letter":
|
||||
return {
|
||||
label: "Percentage",
|
||||
placeholder: "85",
|
||||
min: "0",
|
||||
max: "100",
|
||||
step: "0.01",
|
||||
maxGradeValue: "100",
|
||||
showMaxGradeInput: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: "Grade",
|
||||
placeholder: "85",
|
||||
min: "0",
|
||||
max: undefined,
|
||||
step: "0.01",
|
||||
maxGradeValue: "100",
|
||||
showMaxGradeInput: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await api.exportGradesCSV(selectedPeriod?._id);
|
||||
} catch (error) {
|
||||
console.error("Failed to export grades:", error);
|
||||
alert("Failed to export grades. Please try again.");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
const reportCardData = selectedPeriod
|
||||
? calculateReportCard(
|
||||
subjects,
|
||||
grades,
|
||||
new Date(selectedPeriod.start_date),
|
||||
new Date(selectedPeriod.end_date)
|
||||
)
|
||||
: [];
|
||||
|
||||
const calculatedGPA = calculateOverallGPA(reportCardData);
|
||||
|
||||
// Calculate GPA with teacher grade overrides
|
||||
const getSubjectFinalGrade = (subjectId: string, calculatedAverage: number) => {
|
||||
if (!selectedPeriod) return calculatedAverage;
|
||||
const teacherGrade = teacherGrades.find(
|
||||
tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id
|
||||
);
|
||||
if (!teacherGrade) return calculatedAverage;
|
||||
|
||||
const subject = subjects.find(s => s._id === subjectId);
|
||||
if (!subject) return calculatedAverage;
|
||||
|
||||
// Convert teacher grade to percentage
|
||||
const gradeSystem = subject.grade_system as GradeSystem || "percentage";
|
||||
if (gradeSystem === "german" && teacherGrade.max_grade === 6) {
|
||||
return germanGradeToPercentage(teacherGrade.grade);
|
||||
}
|
||||
return (teacherGrade.grade / teacherGrade.max_grade) * 100;
|
||||
};
|
||||
|
||||
const overallGPA = selectedPeriod && reportCardData.length > 0
|
||||
? reportCardData.reduce((sum, data) => {
|
||||
const finalGrade = getSubjectFinalGrade(data.subject._id, data.overallAverage);
|
||||
return sum + finalGrade;
|
||||
}, 0) / reportCardData.length
|
||||
: calculatedGPA;
|
||||
|
||||
// Use the first subject's grading system as the primary system
|
||||
const primaryGradeSystem = (subjects[0]?.grade_system as GradeSystem) || "percentage";
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Grademaxxing</h1>
|
||||
<p className="text-sm text-gray-600">Welcome back, {user?.username}!</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/periods">
|
||||
<Button variant="ghost">Report Periods</Button>
|
||||
</Link>
|
||||
<Link to="/subjects">
|
||||
<Button variant="secondary">Manage Subjects</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Period Selector */}
|
||||
{periods.length > 0 && (
|
||||
<div className="mb-8 flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Report Period
|
||||
</label>
|
||||
<select
|
||||
value={selectedPeriod?._id || ""}
|
||||
onChange={(e) => {
|
||||
const period = periods.find((p) => p._id === e.target.value);
|
||||
setSelectedPeriod(period || null);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{periods.map((period) => (
|
||||
<option key={period._id} value={period._id}>
|
||||
{period.name} ({new Date(period.start_date).toLocaleDateString()} -{" "}
|
||||
{new Date(period.end_date).toLocaleDateString()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExportCSV}
|
||||
disabled={exporting}
|
||||
variant="secondary"
|
||||
>
|
||||
{exporting ? "Exporting..." : "📥 Export CSV"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Overall Average"
|
||||
value={
|
||||
overallGPA > 0
|
||||
? selectedPeriod && teacherGrades.some(tg => tg.period_id === selectedPeriod._id) && calculatedGPA !== overallGPA
|
||||
? `${formatGradeBySystem(overallGPA, primaryGradeSystem, 1)} (${formatGradeBySystem(calculatedGPA, primaryGradeSystem, 1)})`
|
||||
: formatGradeBySystem(overallGPA, primaryGradeSystem, 1)
|
||||
: "-"
|
||||
}
|
||||
subtitle={selectedPeriod?.name || "All time"}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Subjects"
|
||||
value={subjects.length}
|
||||
subtitle={`${reportCardData.filter((d) => d.grades.length > 0).length} with grades`}
|
||||
color="#10b981"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Grades"
|
||||
value={grades.length}
|
||||
subtitle={`${
|
||||
selectedPeriod
|
||||
? reportCardData.reduce((sum, d) => sum + d.grades.length, 0)
|
||||
: grades.length
|
||||
} in period`}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Improvement Tips */}
|
||||
{selectedPeriod && reportCardData.length > 0 && (
|
||||
(() => {
|
||||
const tipsForSubjects = reportCardData
|
||||
.filter(data => data.categoryAverages.length > 1 && data.categoryAverages.every(c => c.average > 0))
|
||||
.map(data => {
|
||||
const sortedCategories = [...data.categoryAverages].sort((a, b) => a.average - b.average);
|
||||
const weakest = sortedCategories[0];
|
||||
const strongest = sortedCategories[sortedCategories.length - 1];
|
||||
const difference = strongest.average - weakest.average;
|
||||
|
||||
if (difference > 5) {
|
||||
return {
|
||||
subject: data.subject,
|
||||
weakest,
|
||||
strongest,
|
||||
difference
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(tip => tip !== null);
|
||||
|
||||
if (tipsForSubjects.length > 0) {
|
||||
return (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">💡 Improvement Tips</h3>
|
||||
<div className="mt-2 text-sm text-blue-700 space-y-2">
|
||||
{tipsForSubjects.map((tip, idx) => (
|
||||
<p key={idx}>
|
||||
<strong>{tip!.subject.name}:</strong> Your {tip!.weakest.categoryName} ({formatGradeBySystem(
|
||||
tip!.weakest.average,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)}) needs work compared to {tip!.strongest.categoryName} ({formatGradeBySystem(
|
||||
tip!.strongest.average,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)}). Focus on {tip!.weakest.categoryName.toLowerCase()} to improve!
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Report Card */}
|
||||
{selectedPeriod && reportCardData.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Report Card - {selectedPeriod.name}
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">
|
||||
Subject
|
||||
</th>
|
||||
{reportCardData[0]?.categoryAverages.map((cat) => (
|
||||
<th
|
||||
key={cat.categoryName}
|
||||
className="text-right py-3 px-4 font-semibold text-gray-700"
|
||||
>
|
||||
{cat.categoryName} ({cat.weight}%)
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">
|
||||
Final Grade
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">
|
||||
Target
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">
|
||||
Diff
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">
|
||||
Teacher's Grade
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reportCardData.map((data) => {
|
||||
const teacherGrade = selectedPeriod ? teacherGrades.find(
|
||||
tg => tg.subject_id === data.subject._id && tg.period_id === selectedPeriod._id
|
||||
) : null;
|
||||
|
||||
const isEditing = editingTeacherGrade?.subjectId === data.subject._id;
|
||||
|
||||
// Calculate target progress for this subject
|
||||
const targetProgress = calculateTargetProgress(data.overallAverage, data.subject);
|
||||
|
||||
return (
|
||||
<tr key={data.subject._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-medium">{data.subject.name}</td>
|
||||
{data.categoryAverages.map((cat) => (
|
||||
<td key={cat.categoryName} className="text-right py-3 px-4">
|
||||
{cat.average > 0
|
||||
? formatGradeBySystem(
|
||||
cat.average,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)
|
||||
: "-"}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-3 px-4 font-bold">
|
||||
{teacherGrade ? (
|
||||
<span className="text-green-700">
|
||||
{formatGradeBySystem(
|
||||
primaryGradeSystem === "german" && teacherGrade.max_grade === 6
|
||||
? germanGradeToPercentage(teacherGrade.grade)
|
||||
: (teacherGrade.grade / teacherGrade.max_grade) * 100,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)}
|
||||
{data.overallAverage > 0 && (
|
||||
<span className="text-gray-500 text-sm font-normal">
|
||||
{" "}({formatGradeBySystem(
|
||||
data.overallAverage,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-blue-600">
|
||||
{data.overallAverage > 0
|
||||
? formatGradeBySystem(
|
||||
data.overallAverage,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)
|
||||
: "-"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right py-3 px-4 text-gray-600">
|
||||
{targetProgress.status !== "no-target"
|
||||
? primaryGradeSystem === "german" && data.subject.target_grade_max === 6
|
||||
? data.subject.target_grade?.toFixed(1)
|
||||
: formatGradeBySystem(
|
||||
targetProgress.targetPercentage,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className={`text-right py-3 px-4 font-semibold ${getTargetStatusColor(targetProgress.status)}`}>
|
||||
{targetProgress.status !== "no-target" && data.overallAverage > 0
|
||||
? (targetProgress.difference > 0 ? "+" : "") + targetProgress.difference.toFixed(1) + "%"
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="text-right py-3 px-4">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<input
|
||||
type="number"
|
||||
value={editingTeacherGrade.value}
|
||||
onChange={(e) => setEditingTeacherGrade({
|
||||
...editingTeacherGrade,
|
||||
value: e.target.value
|
||||
})}
|
||||
className="w-20 px-2 py-1 border rounded text-sm"
|
||||
placeholder={getGradeInputConfig(primaryGradeSystem).placeholder}
|
||||
min={getGradeInputConfig(primaryGradeSystem).min}
|
||||
max={getGradeInputConfig(primaryGradeSystem).max}
|
||||
step={getGradeInputConfig(primaryGradeSystem).step}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveTeacherGrade}
|
||||
className="text-green-600 hover:text-green-800"
|
||||
title="Save"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingTeacherGrade(null)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
title="Cancel"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
) : teacherGrade ? (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<span className="font-bold text-green-700">
|
||||
{formatGradeBySystem(
|
||||
primaryGradeSystem === "german" && teacherGrade.max_grade === 6
|
||||
? germanGradeToPercentage(teacherGrade.grade)
|
||||
: (teacherGrade.grade / teacherGrade.max_grade) * 100,
|
||||
primaryGradeSystem,
|
||||
1
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleTeacherGradeClick(data.subject._id)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTeacherGrade(data.subject._id)}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="Remove"
|
||||
disabled={deletingTeacherGradeId === data.subject._id}
|
||||
>
|
||||
{deletingTeacherGradeId === data.subject._id ? "..." : "✗"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleTeacherGradeClick(data.subject._id)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-8">
|
||||
<p className="text-yellow-800">
|
||||
{periods.length === 0 ? (
|
||||
<>
|
||||
No report periods defined.{" "}
|
||||
<Link to="/periods" className="underline font-medium">
|
||||
Create your first period
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
"No grades recorded for this period yet."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subjects Grid */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-gray-900">Your Subjects</h2>
|
||||
<Link to="/subjects/new">
|
||||
<Button>+ Add Subject</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{subjects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{subjects.map((subject) => {
|
||||
const subjectGrades = grades.filter((g) => g.subject_id === subject._id);
|
||||
const avgData = reportCardData.find((d) => d.subject._id === subject._id);
|
||||
const teacherGrade = selectedPeriod ? teacherGrades.find(
|
||||
tg => tg.subject_id === subject._id && tg.period_id === selectedPeriod._id
|
||||
) : null;
|
||||
|
||||
const calculatedAverage = avgData?.overallAverage;
|
||||
const finalAverage = calculatedAverage !== undefined
|
||||
? getSubjectFinalGrade(subject._id, calculatedAverage)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SubjectCard
|
||||
key={subject._id}
|
||||
subject={subject}
|
||||
averageGrade={finalAverage}
|
||||
calculatedAverage={calculatedAverage}
|
||||
gradeSystem={primaryGradeSystem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-600 mb-4">No subjects yet. Create your first subject!</p>
|
||||
<Link to="/subjects/new">
|
||||
<Button>+ Add Subject</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
18
app/routes/home.tsx
Normal file
18
app/routes/home.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { api } from "~/api/client";
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to dashboard if authenticated, otherwise to login
|
||||
if (api.isAuthenticated()) {
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
navigate("/login");
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
78
app/routes/login.tsx
Normal file
78
app/routes/login.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router";
|
||||
import { api } from "~/api/client";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await api.login({ email, password });
|
||||
navigate("/dashboard");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Grademaxxing</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className="text-blue-600 hover:text-blue-700 font-medium">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
app/routes/periods.tsx
Normal file
256
app/routes/periods.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ProtectedRoute } from "~/components/ProtectedRoute";
|
||||
import { LoadingSpinner } from "~/components/LoadingSpinner";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
import { api } from "~/api/client";
|
||||
import type { ReportPeriod, ReportPeriodCreate, ReportPeriodUpdate } from "~/types/api";
|
||||
|
||||
export default function ReportPeriods() {
|
||||
const [periods, setPeriods] = useState<ReportPeriod[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingPeriod, setEditingPeriod] = useState<ReportPeriod | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadPeriods();
|
||||
}, []);
|
||||
|
||||
const loadPeriods = async () => {
|
||||
try {
|
||||
const data = await api.getPeriods();
|
||||
setPeriods(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load periods:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (new Date(endDate) <= new Date(startDate)) {
|
||||
setError("End date must be after start date");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingPeriod) {
|
||||
// Update existing period
|
||||
const updateData: ReportPeriodUpdate = {
|
||||
name: name.trim(),
|
||||
start_date: new Date(startDate).toISOString(),
|
||||
end_date: new Date(endDate).toISOString(),
|
||||
};
|
||||
const updated = await api.updatePeriod(editingPeriod._id, updateData);
|
||||
setPeriods(periods.map((p) => (p._id === editingPeriod._id ? updated : p)));
|
||||
} else {
|
||||
// Create new period
|
||||
const periodData: ReportPeriodCreate = {
|
||||
name: name.trim(),
|
||||
start_date: new Date(startDate).toISOString(),
|
||||
end_date: new Date(endDate).toISOString(),
|
||||
};
|
||||
const newPeriod = await api.createPeriod(periodData);
|
||||
setPeriods([newPeriod, ...periods]);
|
||||
}
|
||||
setName("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setShowAddForm(false);
|
||||
setEditingPeriod(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : editingPeriod ? "Failed to update period" : "Failed to create period");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (period: ReportPeriod) => {
|
||||
setEditingPeriod(period);
|
||||
setName(period.name);
|
||||
setStartDate(new Date(period.start_date).toISOString().split("T")[0]);
|
||||
setEndDate(new Date(period.end_date).toISOString().split("T")[0]);
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingPeriod(null);
|
||||
setName("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (periodId: string) => {
|
||||
if (!confirm("Delete this report period?")) return;
|
||||
|
||||
try {
|
||||
await api.deletePeriod(periodId);
|
||||
setPeriods(periods.filter((p) => p._id !== periodId));
|
||||
} catch (error) {
|
||||
alert("Failed to delete period");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link to="/dashboard" className="text-blue-600 hover:text-blue-700 text-sm">
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">Report Periods</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Define time periods for your report cards (e.g., semesters, quarters)
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Add Period Button/Form */}
|
||||
<div className="mb-6">
|
||||
{!showAddForm ? (
|
||||
<Button onClick={() => setShowAddForm(true)}>+ Add Period</Button>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{editingPeriod ? "Edit Report Period" : "Create Report Period"}</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Period Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., First Semester 2024/2025"
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">{editingPeriod ? "Update Period" : "Create Period"}</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Periods List */}
|
||||
{periods.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{periods.map((period) => (
|
||||
<div key={period._id} className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{period.name}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{new Date(period.start_date).toLocaleDateString()} -{" "}
|
||||
{new Date(period.end_date).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Duration:{" "}
|
||||
{Math.ceil(
|
||||
(new Date(period.end_date).getTime() -
|
||||
new Date(period.start_date).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
)}{" "}
|
||||
days
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(period)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
aria-label="Edit period"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(period._id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
aria-label="Delete period"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
No report periods yet. Create your first period!
|
||||
</p>
|
||||
<Button onClick={() => setShowAddForm(true)}>+ Add Period</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
113
app/routes/register.tsx
Normal file
113
app/routes/register.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router";
|
||||
import { api } from "~/api/client";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await api.register({ email, username, password });
|
||||
navigate("/dashboard");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Registration failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Grademaxxing</h1>
|
||||
<p className="text-gray-600 mt-2">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="johndoe"
|
||||
required
|
||||
autoComplete="username"
|
||||
minLength={3}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Creating account..." : "Sign Up"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-blue-600 hover:text-blue-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
305
app/routes/subjects.$subjectId.edit.tsx
Normal file
305
app/routes/subjects.$subjectId.edit.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { useNavigate, useParams, Link } from "react-router";
|
||||
import { ProtectedRoute } from "~/components/ProtectedRoute";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
import { GradingCategorySelector } from "~/components/GradingCategorySelector";
|
||||
import { LoadingSpinner } from "~/components/LoadingSpinner";
|
||||
import { api } from "~/api/client";
|
||||
import type { Subject, GradingCategory } from "~/types/api";
|
||||
import { GRADE_SYSTEMS, type GradeSystem } from "~/utils/gradeSystems";
|
||||
|
||||
const getMaxGradeForSystem = (system: GradeSystem): string => {
|
||||
switch (system) {
|
||||
case "german":
|
||||
return "6";
|
||||
case "us-letter":
|
||||
case "percentage":
|
||||
default:
|
||||
return "100";
|
||||
}
|
||||
};
|
||||
|
||||
export default function EditSubject() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const subjectId = params.subjectId;
|
||||
|
||||
const [subject, setSubject] = useState<Subject | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [teacher, setTeacher] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
const [gradeSystem, setGradeSystem] = useState<GradeSystem>("percentage");
|
||||
const [targetGrade, setTargetGrade] = useState("");
|
||||
const [targetMaxGrade, setTargetMaxGrade] = useState("");
|
||||
const [categories, setCategories] = useState<GradingCategory[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSubject = async () => {
|
||||
if (!subjectId) {
|
||||
setError("Invalid subject ID");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const subjects = await api.getSubjects();
|
||||
const foundSubject = subjects.find((s) => s._id === subjectId);
|
||||
|
||||
if (!foundSubject) {
|
||||
setError("Subject not found");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubject(foundSubject);
|
||||
setName(foundSubject.name);
|
||||
setTeacher(foundSubject.teacher || "");
|
||||
setColor(foundSubject.color ?? "#3b82f6");
|
||||
setGradeSystem((foundSubject.grade_system as GradeSystem) || "percentage");
|
||||
setTargetGrade(foundSubject.target_grade?.toString() || "");
|
||||
setTargetMaxGrade(foundSubject.target_grade_max?.toString() || "");
|
||||
setCategories(foundSubject.grading_categories);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch subject");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubject();
|
||||
}, [subjectId]);
|
||||
|
||||
// Auto-update max grade when grade system changes
|
||||
useEffect(() => {
|
||||
setTargetMaxGrade(getMaxGradeForSystem(gradeSystem));
|
||||
}, [gradeSystem]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Subject name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
setError("Add at least one grading category");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalWeight = categories.reduce((sum, cat) => sum + cat.weight, 0);
|
||||
if (Math.abs(totalWeight - 100) > 0.01) {
|
||||
setError("Category weights must sum to 100%");
|
||||
return;
|
||||
}
|
||||
|
||||
if (categories.some((cat) => !cat.name.trim())) {
|
||||
setError("All categories must have a name");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subjectId) {
|
||||
setError("Invalid subject ID");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await api.updateSubject(subjectId, {
|
||||
name: name.trim(),
|
||||
teacher: teacher.trim() || undefined,
|
||||
color,
|
||||
grade_system: gradeSystem,
|
||||
grading_categories: categories,
|
||||
target_grade: targetGrade ? parseFloat(targetGrade) : undefined,
|
||||
target_grade_max: targetMaxGrade ? parseFloat(targetMaxGrade) : undefined,
|
||||
});
|
||||
navigate(`/subjects/${subjectId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update subject");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !subject) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-6 py-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link
|
||||
to={`/subjects/${subjectId}`}
|
||||
className="text-blue-600 hover:text-blue-700 text-sm"
|
||||
>
|
||||
← Back to {subject?.name || "Subject"}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">Edit Subject</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Subject Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mathematics, English, Biology"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Teacher (Optional)"
|
||||
value={teacher}
|
||||
onChange={(e) => setTeacher(e.target.value)}
|
||||
placeholder="e.g., Mrs. Smith"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subject Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-20 h-10 rounded cursor-pointer border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Grade Display System
|
||||
</label>
|
||||
<select
|
||||
value={gradeSystem}
|
||||
onChange={(e) => setGradeSystem(e.target.value as GradeSystem)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{Object.entries(GRADE_SYSTEMS).map(([key, system]) => (
|
||||
<option key={key} value={key}>
|
||||
{system.displayName} - {system.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Choose how grades will be displayed for this subject
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GradingCategorySelector
|
||||
categories={categories}
|
||||
onChange={setCategories}
|
||||
/>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-4">
|
||||
Target Grade (Optional)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Target Grade"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={targetGrade}
|
||||
onChange={(e) => setTargetGrade(e.target.value)}
|
||||
placeholder={gradeSystem === "german" ? "e.g., 2.0" : "e.g., 85"}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Grade
|
||||
</label>
|
||||
<div className="px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-700">
|
||||
{targetMaxGrade}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Auto-set by grade system
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Set a target grade to track your progress. Leave empty if not needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Grade Display System
|
||||
</label>
|
||||
<select
|
||||
value={gradeSystem}
|
||||
onChange={(e) => setGradeSystem(e.target.value as GradeSystem)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{Object.entries(GRADE_SYSTEMS).map(([key, system]) => (
|
||||
<option key={key} value={key}>
|
||||
{system.displayName} - {system.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Choose how grades will be displayed for this subject
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<GradingCategorySelector
|
||||
categories={categories}
|
||||
onChange={setCategories}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
⚠️ Changing categories may affect how existing grades are calculated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={saving} className="flex-1">
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Link to={`/subjects/${subjectId}`} className="flex-1">
|
||||
<Button type="button" variant="secondary" className="w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
486
app/routes/subjects.$subjectId.tsx
Normal file
486
app/routes/subjects.$subjectId.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router";
|
||||
import { ProtectedRoute } from "~/components/ProtectedRoute";
|
||||
import { GradeTable } from "~/components/GradeTable";
|
||||
import { GradeCalculator } from "~/components/GradeCalculator";
|
||||
import { LoadingSpinner } from "~/components/LoadingSpinner";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
import { api } from "~/api/client";
|
||||
import type { Subject, Grade, GradeCreate, GradeUpdate } from "~/types/api";
|
||||
import { calculateSubjectAverage, calculateTargetProgress, getTargetStatusColor } from "~/utils/gradeCalculations";
|
||||
import { formatGradeBySystem, getGermanGradeDescription, germanGradeToPercentage, type GradeSystem } from "~/utils/gradeSystems";
|
||||
|
||||
export default function SubjectDetail() {
|
||||
const { subjectId } = useParams<{ subjectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [subject, setSubject] = useState<Subject | null>(null);
|
||||
const [grades, setGrades] = useState<Grade[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingGrade, setEditingGrade] = useState<Grade | null>(null);
|
||||
const [deletingGradeId, setDeletingGradeId] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [gradeName, setGradeName] = useState("");
|
||||
const [gradeValue, setGradeValue] = useState("");
|
||||
const [maxGrade, setMaxGrade] = useState("100");
|
||||
const [category, setCategory] = useState("");
|
||||
const [weight, setWeight] = useState("1");
|
||||
const [date, setDate] = useState(new Date().toISOString().split("T")[0]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Get input parameters based on grading system
|
||||
const getGradeInputConfig = () => {
|
||||
const system = (subject?.grade_system || "percentage") as GradeSystem;
|
||||
switch (system) {
|
||||
case "german":
|
||||
return {
|
||||
label: "German Grade",
|
||||
placeholder: "1.0",
|
||||
min: "1",
|
||||
max: "6",
|
||||
step: "0.1",
|
||||
maxGradeValue: "6",
|
||||
showMaxGradeInput: false,
|
||||
helpText: "1 = sehr gut, 6 = ungenügend"
|
||||
};
|
||||
case "us-letter":
|
||||
return {
|
||||
label: "Percentage",
|
||||
placeholder: "85",
|
||||
min: "0",
|
||||
max: "100",
|
||||
step: "0.01",
|
||||
maxGradeValue: "100",
|
||||
showMaxGradeInput: false,
|
||||
helpText: "Enter as percentage (0-100)"
|
||||
};
|
||||
default: // percentage
|
||||
return {
|
||||
label: "Grade",
|
||||
placeholder: "85",
|
||||
min: "0",
|
||||
max: undefined,
|
||||
step: "0.01",
|
||||
maxGradeValue: "100",
|
||||
showMaxGradeInput: true,
|
||||
helpText: undefined
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [subjectId]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!subjectId) return;
|
||||
|
||||
try {
|
||||
const [subjectData, gradesData] = await Promise.all([
|
||||
api.getSubject(subjectId),
|
||||
api.getGrades(subjectId),
|
||||
]);
|
||||
setSubject(subjectData);
|
||||
setGrades(gradesData);
|
||||
if (subjectData.grading_categories.length > 0) {
|
||||
setCategory(subjectData.grading_categories[0].name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load subject:", error);
|
||||
navigate("/subjects");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitGrade = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!subject) return;
|
||||
|
||||
const config = getGradeInputConfig();
|
||||
const finalMaxGrade = config.showMaxGradeInput ? parseFloat(maxGrade) : parseFloat(config.maxGradeValue);
|
||||
|
||||
try {
|
||||
if (editingGrade) {
|
||||
// Update existing grade
|
||||
const updateData: GradeUpdate = {
|
||||
category_name: category,
|
||||
grade: parseFloat(gradeValue),
|
||||
max_grade: finalMaxGrade,
|
||||
weight_in_category: parseFloat(weight),
|
||||
name: gradeName.trim() || undefined,
|
||||
date: new Date(date).toISOString(),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
const updated = await api.updateGrade(editingGrade._id, updateData);
|
||||
setGrades(grades.map((g) => (g._id === editingGrade._id ? updated : g)));
|
||||
} else {
|
||||
// Create new grade
|
||||
const gradeData: GradeCreate = {
|
||||
subject_id: subject._id,
|
||||
category_name: category,
|
||||
grade: parseFloat(gradeValue),
|
||||
max_grade: finalMaxGrade,
|
||||
weight_in_category: parseFloat(weight),
|
||||
name: gradeName.trim() || undefined,
|
||||
date: new Date(date).toISOString(),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
const newGrade = await api.createGrade(gradeData);
|
||||
setGrades([newGrade, ...grades]);
|
||||
}
|
||||
// Reset form
|
||||
setGradeName("");
|
||||
setGradeValue("");
|
||||
setMaxGrade("100");
|
||||
setWeight("1");
|
||||
setDate(new Date().toISOString().split("T")[0]);
|
||||
setNotes("");
|
||||
setShowAddForm(false);
|
||||
setEditingGrade(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : editingGrade ? "Failed to update grade" : "Failed to add grade");
|
||||
}
|
||||
};
|
||||
|
||||
const gradeInputConfig = subject ? getGradeInputConfig() : null;
|
||||
|
||||
const handleEditGrade = (grade: Grade) => {
|
||||
setEditingGrade(grade);
|
||||
setGradeName(grade.name || "");
|
||||
setGradeValue(grade.grade.toString());
|
||||
setMaxGrade(grade.max_grade.toString());
|
||||
setCategory(grade.category_name);
|
||||
setWeight(grade.weight_in_category.toString());
|
||||
setDate(new Date(grade.date).toISOString().split("T")[0]);
|
||||
setNotes(grade.notes || "");
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingGrade(null);
|
||||
setGradeName("");
|
||||
setGradeValue("");
|
||||
setMaxGrade("100");
|
||||
setWeight("1");
|
||||
setDate(new Date().toISOString().split("T")[0]);
|
||||
setNotes("");
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleDeleteGrade = async (gradeId: string) => {
|
||||
if (!confirm("Delete this grade?")) return;
|
||||
|
||||
try {
|
||||
setDeletingGradeId(gradeId);
|
||||
await api.deleteGrade(gradeId);
|
||||
setGrades(grades.filter((g) => g._id !== gradeId));
|
||||
} catch (error) {
|
||||
alert("Failed to delete grade");
|
||||
} finally {
|
||||
setDeletingGradeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avgData = calculateSubjectAverage(subject, grades);
|
||||
const targetProgress = calculateTargetProgress(avgData.overallAverage, subject);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link to="/subjects" className="text-blue-600 hover:text-blue-700 text-sm">
|
||||
← Back to Subjects
|
||||
</Link>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{subject.name}</h1>
|
||||
{subject.teacher && (
|
||||
<p className="text-sm text-gray-600">{subject.teacher}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold" style={{ color: subject.color }}>
|
||||
{avgData.overallAverage > 0 ? formatGradeBySystem(
|
||||
avgData.overallAverage,
|
||||
(subject.grade_system as GradeSystem) || "percentage",
|
||||
1
|
||||
) : "-"}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Overall Average</p>
|
||||
{targetProgress.status !== "no-target" && avgData.overallAverage > 0 && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<div className="flex items-center gap-2 justify-end text-sm">
|
||||
<span className="text-gray-600">Target:</span>
|
||||
<span className="font-semibold">
|
||||
{formatGradeBySystem(
|
||||
targetProgress.targetPercentage,
|
||||
(subject.grade_system as GradeSystem) || "percentage",
|
||||
1
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${getTargetStatusColor(targetProgress.status)}`}>
|
||||
{targetProgress.difference > 0 ? "+" : ""}{targetProgress.difference.toFixed(1)}%
|
||||
{targetProgress.status === "above" ? "above target" : targetProgress.status === "near" ? "near target" : "below target"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Improvement Tip */}
|
||||
{avgData.categoryAverages.length > 1 && avgData.categoryAverages.every(c => c.average > 0) && (
|
||||
(() => {
|
||||
const sortedCategories = [...avgData.categoryAverages].sort((a, b) => a.average - b.average);
|
||||
const weakest = sortedCategories[0];
|
||||
const strongest = sortedCategories[sortedCategories.length - 1];
|
||||
const difference = strongest.average - weakest.average;
|
||||
|
||||
if (difference > 5) { // Only show if there's a significant difference
|
||||
return (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800"><EFBFBD> Improvement Tip</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>
|
||||
Your <strong>{weakest.categoryName}</strong> grades ({formatGradeBySystem(
|
||||
weakest.average,
|
||||
(subject.grade_system as GradeSystem) || "percentage",
|
||||
1
|
||||
)}) are lower than your <strong>{strongest.categoryName}</strong> ({formatGradeBySystem(
|
||||
strongest.average,
|
||||
(subject.grade_system as GradeSystem) || "percentage",
|
||||
1
|
||||
)}).
|
||||
Focus more on improving your {weakest.categoryName.toLowerCase()} performance to boost your overall grade!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Grade Calculator */}
|
||||
<div className="mb-6">
|
||||
<GradeCalculator subject={subject} currentGrades={grades} />
|
||||
</div>
|
||||
|
||||
{/* Category Averages */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
{avgData.categoryAverages.map((cat) => (
|
||||
<div
|
||||
key={cat.categoryName}
|
||||
className="bg-white rounded-lg border-2 p-4"
|
||||
style={{ borderColor: cat.color || "#e5e7eb" }}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-600">{cat.categoryName}</p>
|
||||
<p className="text-2xl font-bold mt-1" style={{ color: cat.color }}>
|
||||
{cat.average > 0 ? formatGradeBySystem(
|
||||
cat.average,
|
||||
(subject.grade_system as GradeSystem) || "percentage",
|
||||
1
|
||||
) : "-"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Weight: {cat.weight}%</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Grade Button/Form */}
|
||||
<div className="mb-6">
|
||||
{!showAddForm ? (
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => setShowAddForm(true)}>+ Add Grade</Button>
|
||||
<Link to={`/subjects/${subjectId}/edit`}>
|
||||
<Button variant="secondary">Edit Subject</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{editingGrade ? "Edit Grade" : "Add New Grade"}</h3>
|
||||
<form onSubmit={handleSubmitGrade} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Grade Name (Optional)"
|
||||
value={gradeName}
|
||||
onChange={(e) => setGradeName(e.target.value)}
|
||||
placeholder="e.g., Midterm Exam"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
required
|
||||
>
|
||||
{subject.grading_categories.map((cat) => (
|
||||
<option key={cat.name} value={cat.name}>
|
||||
{cat.name} ({cat.weight}%)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label={gradeInputConfig?.label || "Grade"}
|
||||
type="number"
|
||||
value={gradeValue}
|
||||
onChange={(e) => setGradeValue(e.target.value)}
|
||||
placeholder={gradeInputConfig?.placeholder || "85"}
|
||||
required
|
||||
min={gradeInputConfig?.min || "0"}
|
||||
max={gradeInputConfig?.max}
|
||||
step={gradeInputConfig?.step || "0.01"}
|
||||
/>
|
||||
{gradeInputConfig?.helpText && (
|
||||
<p className="text-xs text-gray-500 mt-1">{gradeInputConfig.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
{gradeInputConfig?.showMaxGradeInput && (
|
||||
<Input
|
||||
label="Max Grade"
|
||||
type="number"
|
||||
value={maxGrade}
|
||||
onChange={(e) => setMaxGrade(e.target.value)}
|
||||
placeholder="100"
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grade Preview */}
|
||||
{gradeValue && gradeInputConfig && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-blue-900 mb-1">Grade Preview</div>
|
||||
{subject?.grade_system === "german" ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-blue-700">
|
||||
{parseFloat(gradeValue).toFixed(1)}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600 mt-1">
|
||||
{getGermanGradeDescription(parseFloat(gradeValue))}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 mt-2">
|
||||
Percentage equivalent: {germanGradeToPercentage(parseFloat(gradeValue)).toFixed(1)}%
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-blue-700">
|
||||
{formatGradeBySystem(
|
||||
gradeInputConfig.showMaxGradeInput && maxGrade && parseFloat(maxGrade) > 0
|
||||
? (parseFloat(gradeValue) / parseFloat(maxGrade)) * 100
|
||||
: parseFloat(gradeValue),
|
||||
(subject?.grade_system as GradeSystem) || "percentage"
|
||||
)}
|
||||
</div>
|
||||
{gradeInputConfig.showMaxGradeInput && maxGrade && parseFloat(maxGrade) > 0 && (
|
||||
<div className="text-xs text-blue-600 mt-2">
|
||||
Percentage: {((parseFloat(gradeValue) / parseFloat(maxGrade)) * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Weight in Category"
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
placeholder="1"
|
||||
required
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
<Input
|
||||
label="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Notes (Optional)"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Additional notes..."
|
||||
/>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">{editingGrade ? "Update Grade" : "Add Grade"}</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grades Table */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">All Grades</h3>
|
||||
<GradeTable
|
||||
grades={grades}
|
||||
subject={subject}
|
||||
onEdit={handleEditGrade}
|
||||
onDelete={handleDeleteGrade}
|
||||
deletingGradeId={deletingGradeId}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
206
app/routes/subjects.new.tsx
Normal file
206
app/routes/subjects.new.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router";
|
||||
import { ProtectedRoute } from "~/components/ProtectedRoute";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
import { GradingCategorySelector } from "~/components/GradingCategorySelector";
|
||||
import { api } from "~/api/client";
|
||||
import type { GradingCategory } from "~/types/api";
|
||||
import { GRADE_SYSTEMS, type GradeSystem } from "~/utils/gradeSystems";
|
||||
|
||||
const getMaxGradeForSystem = (system: GradeSystem): string => {
|
||||
switch (system) {
|
||||
case "german":
|
||||
return "6";
|
||||
case "us-letter":
|
||||
case "percentage":
|
||||
default:
|
||||
return "100";
|
||||
}
|
||||
};
|
||||
|
||||
export default function NewSubject() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [teacher, setTeacher] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
const [gradeSystem, setGradeSystem] = useState<GradeSystem>("percentage");
|
||||
const [targetGrade, setTargetGrade] = useState("");
|
||||
const [targetMaxGrade, setTargetMaxGrade] = useState("100");
|
||||
|
||||
// Auto-update max grade when grade system changes
|
||||
useEffect(() => {
|
||||
setTargetMaxGrade(getMaxGradeForSystem(gradeSystem));
|
||||
}, [gradeSystem]);
|
||||
const [categories, setCategories] = useState<GradingCategory[]>([
|
||||
{ name: "Written", weight: 50, color: "#3b82f6" },
|
||||
{ name: "Oral", weight: 50, color: "#10b981" },
|
||||
]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Subject name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
setError("Add at least one grading category");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalWeight = categories.reduce((sum, cat) => sum + cat.weight, 0);
|
||||
if (Math.abs(totalWeight - 100) > 0.01) {
|
||||
setError("Category weights must sum to 100%");
|
||||
return;
|
||||
}
|
||||
|
||||
if (categories.some((cat) => !cat.name.trim())) {
|
||||
setError("All categories must have a name");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await api.createSubject({
|
||||
name: name.trim(),
|
||||
teacher: teacher.trim() || undefined,
|
||||
color,
|
||||
grade_system: gradeSystem,
|
||||
grading_categories: categories,
|
||||
target_grade: targetGrade ? parseFloat(targetGrade) : undefined,
|
||||
target_grade_max: targetMaxGrade ? parseFloat(targetMaxGrade) : undefined,
|
||||
});
|
||||
navigate("/subjects");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create subject");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link to="/subjects" className="text-blue-600 hover:text-blue-700 text-sm">
|
||||
← Back to Subjects
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">Create New Subject</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Subject Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mathematics, English, Biology"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Teacher (Optional)"
|
||||
value={teacher}
|
||||
onChange={(e) => setTeacher(e.target.value)}
|
||||
placeholder="e.g., Mrs. Smith"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subject Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-20 h-10 rounded cursor-pointer border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Grade Display System
|
||||
</label>
|
||||
<select
|
||||
value={gradeSystem}
|
||||
onChange={(e) => setGradeSystem(e.target.value as GradeSystem)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{Object.entries(GRADE_SYSTEMS).map(([key, system]) => (
|
||||
<option key={key} value={key}>
|
||||
{system.displayName} - {system.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Choose how grades will be displayed for this subject
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GradingCategorySelector
|
||||
categories={categories}
|
||||
onChange={setCategories}
|
||||
/>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-4">
|
||||
Target Grade (Optional)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Target Grade"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={targetGrade}
|
||||
onChange={(e) => setTargetGrade(e.target.value)}
|
||||
placeholder={gradeSystem === "german" ? "e.g., 2.0" : "e.g., 85"}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Grade
|
||||
</label>
|
||||
<div className="px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-700">
|
||||
{targetMaxGrade}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Auto-set by grade system
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Set a target grade to track your progress. Leave empty if not needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Creating..." : "Create Subject"}
|
||||
</Button>
|
||||
<Link to="/subjects" className="flex-1">
|
||||
<Button type="button" variant="secondary" className="w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
110
app/routes/subjects.tsx
Normal file
110
app/routes/subjects.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ProtectedRoute } from "~/components/ProtectedRoute";
|
||||
import { SubjectCard } from "~/components/SubjectCard";
|
||||
import { LoadingSpinner } from "~/components/LoadingSpinner";
|
||||
import { Button } from "~/components/Button";
|
||||
import { api } from "~/api/client";
|
||||
import type { Subject, Grade } from "~/types/api";
|
||||
import { calculateSubjectAverage } from "~/utils/gradeCalculations";
|
||||
|
||||
export default function SubjectsList() {
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [grades, setGrades] = useState<Grade[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [subjectsData, gradesData] = await Promise.all([
|
||||
api.getSubjects(),
|
||||
api.getGrades(),
|
||||
]);
|
||||
setSubjects(subjectsData);
|
||||
setGrades(gradesData);
|
||||
} catch (error) {
|
||||
console.error("Failed to load subjects:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (subjectId: string) => {
|
||||
if (!confirm("Are you sure? This will delete all grades for this subject.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteSubject(subjectId);
|
||||
setSubjects(subjects.filter((s) => s._id !== subjectId));
|
||||
setGrades(grades.filter((g) => g.subject_id !== subjectId));
|
||||
} catch (error) {
|
||||
alert("Failed to delete subject");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
// Use the first subject's grading system as the primary system
|
||||
const primaryGradeSystem = subjects[0]?.grade_system || "percentage";
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<Link to="/dashboard" className="text-blue-600 hover:text-blue-700 text-sm">
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">Manage Subjects</h1>
|
||||
</div>
|
||||
<Link to="/subjects/new">
|
||||
<Button>+ Add Subject</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{subjects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{subjects.map((subject) => {
|
||||
const subjectGrades = grades.filter((g) => g.subject_id === subject._id);
|
||||
const avgData = calculateSubjectAverage(subject, subjectGrades);
|
||||
return (
|
||||
<SubjectCard
|
||||
key={subject._id}
|
||||
subject={subject}
|
||||
averageGrade={avgData.overallAverage}
|
||||
gradeSystem={primaryGradeSystem}
|
||||
onDelete={() => handleDelete(subject._id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-600 mb-4">No subjects yet. Create your first subject!</p>
|
||||
<Link to="/subjects/new">
|
||||
<Button>+ Add Subject</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
149
app/types/api.ts
Normal file
149
app/types/api.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// API Types matching backend models
|
||||
|
||||
export interface GradingCategory {
|
||||
name: string;
|
||||
weight: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface Subject {
|
||||
_id: string;
|
||||
name: string;
|
||||
grading_categories: GradingCategory[];
|
||||
color?: string;
|
||||
teacher?: string;
|
||||
grade_system?: string;
|
||||
target_grade?: number;
|
||||
target_grade_max?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SubjectCreate {
|
||||
name: string;
|
||||
grading_categories: GradingCategory[];
|
||||
color?: string;
|
||||
teacher?: string;
|
||||
grade_system?: string;
|
||||
target_grade?: number;
|
||||
target_grade_max?: number;
|
||||
}
|
||||
|
||||
export interface SubjectUpdate {
|
||||
name?: string;
|
||||
grading_categories?: GradingCategory[];
|
||||
color?: string;
|
||||
teacher?: string;
|
||||
grade_system?: string;
|
||||
target_grade?: number;
|
||||
target_grade_max?: number;
|
||||
}
|
||||
|
||||
export interface Grade {
|
||||
_id: string;
|
||||
subject_id: string;
|
||||
category_name: string;
|
||||
grade: number;
|
||||
max_grade: number;
|
||||
weight_in_category: number;
|
||||
name?: string;
|
||||
date: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GradeCreate {
|
||||
subject_id: string;
|
||||
category_name: string;
|
||||
grade: number;
|
||||
max_grade: number;
|
||||
weight_in_category?: number;
|
||||
name?: string;
|
||||
date?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface GradeUpdate {
|
||||
category_name?: string;
|
||||
grade?: number;
|
||||
max_grade?: number;
|
||||
weight_in_category?: number;
|
||||
name?: string;
|
||||
date?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ReportPeriod {
|
||||
_id: string;
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReportPeriodCreate {
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
export interface ReportPeriodUpdate {
|
||||
name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
export interface TeacherGrade {
|
||||
_id: string;
|
||||
subject_id: string;
|
||||
period_id: string;
|
||||
grade: number;
|
||||
max_grade: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TeacherGradeCreate {
|
||||
subject_id: string;
|
||||
period_id: string;
|
||||
grade: number;
|
||||
max_grade: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface TeacherGradeUpdate {
|
||||
grade?: number;
|
||||
max_grade?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserRegister {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
}
|
||||
431
app/utils/gradeCalculations.ts
Normal file
431
app/utils/gradeCalculations.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import type { Subject, Grade, GradingCategory } from "~/types/api";
|
||||
import { germanGradeToPercentage, percentageToGermanGrade } from "./gradeSystems";
|
||||
|
||||
export interface CategoryAverage {
|
||||
categoryName: string;
|
||||
average: number; // Always stored as percentage (0-100) for calculations
|
||||
weight: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SubjectGradeData {
|
||||
subject: Subject;
|
||||
grades: Grade[];
|
||||
categoryAverages: CategoryAverage[];
|
||||
overallAverage: number; // Always stored as percentage (0-100) for calculations
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the weighted average for a specific grading category
|
||||
* @param grades - Array of grades in the category
|
||||
* @param isGermanSystem - Whether the grades use the German 1-6 system (inverted scale)
|
||||
*/
|
||||
export function calculateCategoryAverage(grades: Grade[], isGermanSystem: boolean = false): number {
|
||||
if (grades.length === 0) return 0;
|
||||
|
||||
const totalWeight = grades.reduce((sum, grade) => sum + grade.weight_in_category, 0);
|
||||
if (totalWeight === 0) return 0;
|
||||
|
||||
const weightedSum = grades.reduce((sum, grade) => {
|
||||
// Normalize grade to percentage
|
||||
let percentage: number;
|
||||
|
||||
if (isGermanSystem && grade.max_grade === 6) {
|
||||
// For German grades (1-6 scale), convert to percentage using the conversion function
|
||||
percentage = germanGradeToPercentage(grade.grade);
|
||||
} else {
|
||||
// For standard percentage-based grades
|
||||
percentage = (grade.grade / grade.max_grade) * 100;
|
||||
}
|
||||
|
||||
return sum + percentage * grade.weight_in_category;
|
||||
}, 0);
|
||||
|
||||
return weightedSum / totalWeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the overall average for a subject based on category weights
|
||||
*/
|
||||
export function calculateSubjectAverage(
|
||||
subject: Subject,
|
||||
grades: Grade[]
|
||||
): SubjectGradeData {
|
||||
const categoryAverages: CategoryAverage[] = [];
|
||||
const isGermanSystem = subject.grade_system === "german";
|
||||
|
||||
// Group grades by category
|
||||
const gradesByCategory = new Map<string, Grade[]>();
|
||||
grades.forEach((grade) => {
|
||||
const existing = gradesByCategory.get(grade.category_name) || [];
|
||||
existing.push(grade);
|
||||
gradesByCategory.set(grade.category_name, existing);
|
||||
});
|
||||
|
||||
// Calculate average for each category
|
||||
subject.grading_categories.forEach((category) => {
|
||||
const categoryGrades = gradesByCategory.get(category.name) || [];
|
||||
const average = calculateCategoryAverage(categoryGrades, isGermanSystem);
|
||||
|
||||
categoryAverages.push({
|
||||
categoryName: category.name,
|
||||
average,
|
||||
weight: category.weight,
|
||||
color: category.color,
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate overall weighted average
|
||||
const overallAverage = categoryAverages.reduce((sum, cat) => {
|
||||
return sum + (cat.average * cat.weight) / 100;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
subject,
|
||||
grades,
|
||||
categoryAverages,
|
||||
overallAverage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate report card averages for all subjects within a date range
|
||||
*/
|
||||
export function calculateReportCard(
|
||||
subjects: Subject[],
|
||||
allGrades: Grade[],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): SubjectGradeData[] {
|
||||
return subjects.map((subject) => {
|
||||
// Filter grades for this subject within the date range
|
||||
const subjectGrades = allGrades.filter((grade) => {
|
||||
const gradeDate = new Date(grade.date);
|
||||
return (
|
||||
grade.subject_id === subject._id &&
|
||||
gradeDate >= startDate &&
|
||||
gradeDate <= endDate
|
||||
);
|
||||
});
|
||||
|
||||
return calculateSubjectAverage(subject, subjectGrades);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall GPA across all subjects
|
||||
*/
|
||||
export function calculateOverallGPA(reportCardData: SubjectGradeData[]): number {
|
||||
if (reportCardData.length === 0) return 0;
|
||||
|
||||
const sum = reportCardData.reduce(
|
||||
(total, data) => total + data.overallAverage,
|
||||
0
|
||||
);
|
||||
|
||||
return sum / reportCardData.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage grade to a specific decimal place
|
||||
*/
|
||||
export function formatGrade(grade: number, decimals: number = 2): string {
|
||||
return grade.toFixed(decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a grade for display based on the grading system
|
||||
* For German system: converts percentage to 1-6 scale (e.g., "2.3")
|
||||
* For other systems: shows percentage with % sign (e.g., "85%")
|
||||
*
|
||||
* @param percentage - Grade as percentage (0-100)
|
||||
* @param gradeSystem - The grading system being used
|
||||
* @param decimals - Number of decimal places
|
||||
*/
|
||||
export function formatGradeForDisplay(
|
||||
percentage: number,
|
||||
gradeSystem: string = "percentage",
|
||||
decimals: number = 1
|
||||
): string {
|
||||
if (gradeSystem === "german") {
|
||||
const germanGrade = percentageToGermanGrade(percentage);
|
||||
return germanGrade.toFixed(decimals);
|
||||
}
|
||||
return `${percentage.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the native grade value (not percentage) based on the grading system
|
||||
* For German system: returns 1-6 scale value
|
||||
* For other systems: returns percentage
|
||||
*
|
||||
* @param percentage - Grade as percentage (0-100)
|
||||
* @param gradeSystem - The grading system being used
|
||||
*/
|
||||
export function getDisplayGrade(
|
||||
percentage: number,
|
||||
gradeSystem: string = "percentage"
|
||||
): number {
|
||||
if (gradeSystem === "german") {
|
||||
return percentageToGermanGrade(percentage);
|
||||
}
|
||||
return percentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert percentage to letter grade (US system)
|
||||
*/
|
||||
export function percentageToLetterGrade(percentage: number): string {
|
||||
if (percentage >= 90) return "A";
|
||||
if (percentage >= 80) return "B";
|
||||
if (percentage >= 70) return "C";
|
||||
if (percentage >= 60) return "D";
|
||||
return "F";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for grade based on percentage
|
||||
*/
|
||||
export function getGradeColor(percentage: number): string {
|
||||
if (percentage >= 90) return "text-green-600";
|
||||
if (percentage >= 80) return "text-blue-600";
|
||||
if (percentage >= 70) return "text-yellow-600";
|
||||
if (percentage >= 60) return "text-orange-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
|
||||
export interface TargetProgress {
|
||||
currentGrade: number;
|
||||
targetGrade: number;
|
||||
targetMaxGrade: number;
|
||||
currentPercentage: number;
|
||||
targetPercentage: number;
|
||||
difference: number; // Positive = above target, negative = below target
|
||||
percentageDifference: number; // Difference as percentage
|
||||
status: "above" | "near" | "below" | "no-target"; // above = exceeds target, near = within 5%, below = more than 5% below
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate target progress for a subject
|
||||
* @param currentAverage - Current subject average (as percentage)
|
||||
* @param subject - Subject with optional target_grade and target_grade_max
|
||||
*/
|
||||
export function calculateTargetProgress(
|
||||
currentAverage: number,
|
||||
subject: Subject
|
||||
): TargetProgress {
|
||||
// No target set
|
||||
if (!subject.target_grade || !subject.target_grade_max) {
|
||||
return {
|
||||
currentGrade: currentAverage,
|
||||
targetGrade: 0,
|
||||
targetMaxGrade: 100,
|
||||
currentPercentage: currentAverage,
|
||||
targetPercentage: 0,
|
||||
difference: 0,
|
||||
percentageDifference: 0,
|
||||
status: "no-target",
|
||||
};
|
||||
}
|
||||
|
||||
// Convert target to percentage
|
||||
const isGermanSystem = subject.grade_system === "german" && subject.target_grade_max === 6;
|
||||
let targetPercentage: number;
|
||||
|
||||
if (isGermanSystem) {
|
||||
// For German grades (1-6 scale), convert to percentage using the conversion function
|
||||
targetPercentage = germanGradeToPercentage(subject.target_grade);
|
||||
} else {
|
||||
// For standard percentage-based grades
|
||||
targetPercentage = (subject.target_grade / subject.target_grade_max) * 100;
|
||||
}
|
||||
|
||||
const difference = currentAverage - targetPercentage;
|
||||
const percentageDifference = targetPercentage > 0 ? (difference / targetPercentage) * 100 : 0;
|
||||
|
||||
// Determine status
|
||||
let status: "above" | "near" | "below";
|
||||
if (difference > 0) {
|
||||
status = "above";
|
||||
} else if (Math.abs(difference) <= 5) {
|
||||
status = "near";
|
||||
} else {
|
||||
status = "below";
|
||||
}
|
||||
|
||||
return {
|
||||
currentGrade: currentAverage,
|
||||
targetGrade: subject.target_grade,
|
||||
targetMaxGrade: subject.target_grade_max,
|
||||
currentPercentage: currentAverage,
|
||||
targetPercentage,
|
||||
difference,
|
||||
percentageDifference,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for target progress status
|
||||
*/
|
||||
export function getTargetStatusColor(status: TargetProgress["status"]): string {
|
||||
switch (status) {
|
||||
case "above":
|
||||
return "text-green-600";
|
||||
case "near":
|
||||
return "text-yellow-600";
|
||||
case "below":
|
||||
return "text-red-600";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background color class for target progress status
|
||||
*/
|
||||
export function getTargetStatusBgColor(status: TargetProgress["status"]): string {
|
||||
switch (status) {
|
||||
case "above":
|
||||
return "bg-green-100 border-green-500";
|
||||
case "near":
|
||||
return "bg-yellow-100 border-yellow-500";
|
||||
case "below":
|
||||
return "bg-red-100 border-red-500";
|
||||
default:
|
||||
return "bg-gray-100 border-gray-300";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequiredGradeResult {
|
||||
requiredGrade: number; // Grade needed on remaining assignments
|
||||
maxGrade: number; // Maximum grade value in the system
|
||||
requiredPercentage: number; // Required grade as percentage
|
||||
isPossible: boolean; // Whether target is achievable
|
||||
categoryName: string;
|
||||
categoryWeight: number;
|
||||
message: string; // Human-readable explanation
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate what grade is needed on remaining assignments to reach a target grade
|
||||
*
|
||||
* @param subject - The subject with grading categories
|
||||
* @param currentGrades - Current grades for the subject
|
||||
* @param targetPercentage - Target overall average (as percentage)
|
||||
* @param remainingAssignments - Number of remaining assignments per category
|
||||
* @param maxGrade - Maximum grade value (e.g., 6 for German, 100 for percentage)
|
||||
* @returns Array of required grades per category
|
||||
*/
|
||||
export function calculateRequiredGrade(
|
||||
subject: Subject,
|
||||
currentGrades: Grade[],
|
||||
targetPercentage: number,
|
||||
remainingAssignments: { [categoryName: string]: number },
|
||||
maxGrade: number = 100
|
||||
): RequiredGradeResult[] {
|
||||
const isGermanSystem = subject.grade_system === "german" && maxGrade === 6;
|
||||
|
||||
// Group grades by category
|
||||
const gradesByCategory = new Map<string, Grade[]>();
|
||||
currentGrades.forEach((grade) => {
|
||||
const existing = gradesByCategory.get(grade.category_name) || [];
|
||||
existing.push(grade);
|
||||
gradesByCategory.set(grade.category_name, existing);
|
||||
});
|
||||
|
||||
const results: RequiredGradeResult[] = [];
|
||||
|
||||
subject.grading_categories.forEach((category) => {
|
||||
const categoryGrades = gradesByCategory.get(category.name) || [];
|
||||
const currentCategoryAverage = calculateCategoryAverage(categoryGrades, isGermanSystem);
|
||||
const remainingCount = remainingAssignments[category.name] || 0;
|
||||
|
||||
// Calculate what category average is needed
|
||||
// targetPercentage = sum of (categoryAvg * categoryWeight / 100)
|
||||
// We need to solve for the new category average considering remaining assignments
|
||||
|
||||
let requiredPercentage: number;
|
||||
let isPossible = true;
|
||||
let message = "";
|
||||
|
||||
if (remainingCount === 0) {
|
||||
// No remaining assignments in this category
|
||||
requiredPercentage = currentCategoryAverage;
|
||||
isPossible = Math.abs(currentCategoryAverage - targetPercentage) < 0.01;
|
||||
message = "No remaining assignments in this category";
|
||||
} else {
|
||||
// Calculate the contribution of other categories at their current averages
|
||||
let otherCategoriesContribution = 0;
|
||||
subject.grading_categories.forEach((cat) => {
|
||||
if (cat.name !== category.name) {
|
||||
const catGrades = gradesByCategory.get(cat.name) || [];
|
||||
const catAvg = calculateCategoryAverage(catGrades, isGermanSystem);
|
||||
otherCategoriesContribution += (catAvg * cat.weight) / 100;
|
||||
}
|
||||
});
|
||||
|
||||
// Required contribution from this category
|
||||
const requiredContribution = targetPercentage - otherCategoriesContribution;
|
||||
requiredPercentage = (requiredContribution * 100) / category.weight;
|
||||
|
||||
// Calculate what grade percentage is needed on new assignments
|
||||
// New category average = (currentSum + requiredGrade * remainingCount) / (currentCount + remainingCount)
|
||||
const currentCount = categoryGrades.length;
|
||||
const currentTotalWeight = categoryGrades.reduce((sum, g) => sum + g.weight_in_category, 0);
|
||||
|
||||
// Assuming each new assignment has weight = 1
|
||||
const newTotalWeight = currentTotalWeight + remainingCount;
|
||||
|
||||
// currentCategoryAverage = currentSum / currentTotalWeight
|
||||
// So currentSum = currentCategoryAverage * currentTotalWeight
|
||||
const currentSum = currentCategoryAverage * currentTotalWeight;
|
||||
|
||||
// requiredPercentage = (currentSum + requiredGradePercentage * remainingCount) / newTotalWeight
|
||||
// Solve for requiredGradePercentage:
|
||||
requiredPercentage = (requiredPercentage * newTotalWeight - currentSum) / remainingCount;
|
||||
|
||||
// Check if achievable
|
||||
if (requiredPercentage > 100) {
|
||||
isPossible = false;
|
||||
const maxDisplay = formatGradeForDisplay(100, subject.grade_system);
|
||||
message = `Target unreachable - would need ${formatGradeForDisplay(requiredPercentage, subject.grade_system)} (>${maxDisplay})`;
|
||||
} else if (requiredPercentage < 0) {
|
||||
isPossible = true;
|
||||
const minDisplay = formatGradeForDisplay(0, subject.grade_system);
|
||||
message = `Target already exceeded! You can score ${minDisplay} and still reach your goal.`;
|
||||
requiredPercentage = 0;
|
||||
} else {
|
||||
isPossible = true;
|
||||
message = `Need ${formatGradeForDisplay(requiredPercentage, subject.grade_system)} average on ${remainingCount} remaining assignment${remainingCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert percentage to grade value
|
||||
let requiredGrade: number;
|
||||
if (isGermanSystem) {
|
||||
// For German system, convert percentage back to 1-6 scale
|
||||
// This is an approximation - exact conversion depends on the grading curve
|
||||
if (requiredPercentage >= 92) requiredGrade = 1.0;
|
||||
else if (requiredPercentage >= 81) requiredGrade = 2.0;
|
||||
else if (requiredPercentage >= 67) requiredGrade = 3.0;
|
||||
else if (requiredPercentage >= 50) requiredGrade = 4.0;
|
||||
else if (requiredPercentage >= 30) requiredGrade = 5.0;
|
||||
else requiredGrade = 6.0;
|
||||
} else {
|
||||
requiredGrade = (requiredPercentage / 100) * maxGrade;
|
||||
}
|
||||
|
||||
results.push({
|
||||
requiredGrade,
|
||||
maxGrade,
|
||||
requiredPercentage,
|
||||
isPossible,
|
||||
categoryName: category.name,
|
||||
categoryWeight: category.weight,
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
179
app/utils/gradeSystems.ts
Normal file
179
app/utils/gradeSystems.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* German Grading System Utilities
|
||||
* German grades: 1 (best) to 6 (worst)
|
||||
*/
|
||||
|
||||
export type GradeSystem = "percentage" | "german" | "us-letter";
|
||||
|
||||
export interface GradeSystemConfig {
|
||||
type: GradeSystem;
|
||||
displayName: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const GRADE_SYSTEMS: Record<GradeSystem, GradeSystemConfig> = {
|
||||
percentage: {
|
||||
type: "percentage",
|
||||
displayName: "Percentage (0-100%)",
|
||||
description: "Standard percentage-based grading",
|
||||
},
|
||||
german: {
|
||||
type: "german",
|
||||
displayName: "German (1-6)",
|
||||
description: "1 = sehr gut (very good), 6 = ungenügend (insufficient)",
|
||||
},
|
||||
"us-letter": {
|
||||
type: "us-letter",
|
||||
displayName: "US Letter (A-F)",
|
||||
description: "A = 90-100%, F = below 60%",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert percentage (0-100) to German grade (1-6)
|
||||
* German grading scale:
|
||||
* 1 (sehr gut / very good): 92-100%
|
||||
* 2 (gut / good): 81-91%
|
||||
* 3 (befriedigend / satisfactory): 67-80%
|
||||
* 4 (ausreichend / sufficient): 50-66%
|
||||
* 5 (mangelhaft / deficient): 30-49%
|
||||
* 6 (ungenügend / insufficient): 0-29%
|
||||
*/
|
||||
export function percentageToGermanGrade(percentage: number): number {
|
||||
if (percentage >= 92) return 1.0;
|
||||
if (percentage >= 81) return 2.0;
|
||||
if (percentage >= 67) return 3.0;
|
||||
if (percentage >= 50) return 4.0;
|
||||
if (percentage >= 30) return 5.0;
|
||||
return 6.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert percentage to German grade with decimal precision
|
||||
* Uses linear interpolation within each grade range
|
||||
*/
|
||||
export function percentageToGermanGradeDetailed(percentage: number): number {
|
||||
if (percentage >= 92) {
|
||||
// 1.0 - 1.5 range (92-100%)
|
||||
const position = (100 - percentage) / (100 - 92);
|
||||
return 1.0 + position * 0.5;
|
||||
} else if (percentage >= 81) {
|
||||
// 1.5 - 2.5 range (81-91%)
|
||||
const position = (92 - percentage) / (92 - 81);
|
||||
return 1.5 + position * 1.0;
|
||||
} else if (percentage >= 67) {
|
||||
// 2.5 - 3.5 range (67-80%)
|
||||
const position = (81 - percentage) / (81 - 67);
|
||||
return 2.5 + position * 1.0;
|
||||
} else if (percentage >= 50) {
|
||||
// 3.5 - 4.5 range (50-66%)
|
||||
const position = (67 - percentage) / (67 - 50);
|
||||
return 3.5 + position * 1.0;
|
||||
} else if (percentage >= 30) {
|
||||
// 4.5 - 5.5 range (30-49%)
|
||||
const position = (50 - percentage) / (50 - 30);
|
||||
return 4.5 + position * 1.0;
|
||||
} else {
|
||||
// 5.5 - 6.0 range (0-29%)
|
||||
const position = (30 - percentage) / 30;
|
||||
return 5.5 + position * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert German grade (1-6) to approximate percentage
|
||||
*/
|
||||
export function germanGradeToPercentage(grade: number): number {
|
||||
if (grade <= 1.5) return 92 + (1.5 - grade) * 16; // 92-100%
|
||||
if (grade <= 2.5) return 81 + (2.5 - grade) * 11; // 81-91%
|
||||
if (grade <= 3.5) return 67 + (3.5 - grade) * 14; // 67-80%
|
||||
if (grade <= 4.5) return 50 + (4.5 - grade) * 17; // 50-66%
|
||||
if (grade <= 5.5) return 30 + (5.5 - grade) * 20; // 30-49%
|
||||
return Math.max(0, 30 - (grade - 5.5) * 60); // 0-29%
|
||||
}
|
||||
|
||||
/**
|
||||
* Get German grade description
|
||||
*/
|
||||
export function getGermanGradeDescription(grade: number): string {
|
||||
if (grade <= 1.5) return "sehr gut (very good)";
|
||||
if (grade <= 2.5) return "gut (good)";
|
||||
if (grade <= 3.5) return "befriedigend (satisfactory)";
|
||||
if (grade <= 4.5) return "ausreichend (sufficient)";
|
||||
if (grade <= 5.5) return "mangelhaft (deficient)";
|
||||
return "ungenügend (insufficient)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for German grade
|
||||
*/
|
||||
export function getGermanGradeColor(grade: number): string {
|
||||
if (grade <= 2.0) return "text-green-600";
|
||||
if (grade <= 3.0) return "text-blue-600";
|
||||
if (grade <= 4.0) return "text-yellow-600";
|
||||
if (grade <= 5.0) return "text-orange-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format grade based on system
|
||||
*/
|
||||
export function formatGradeBySystem(
|
||||
percentage: number,
|
||||
system: GradeSystem,
|
||||
decimals: number = 2
|
||||
): string {
|
||||
switch (system) {
|
||||
case "german": {
|
||||
const germanGrade = percentageToGermanGradeDetailed(percentage);
|
||||
return germanGrade.toFixed(1);
|
||||
}
|
||||
case "us-letter": {
|
||||
if (percentage >= 90) return "A";
|
||||
if (percentage >= 80) return "B";
|
||||
if (percentage >= 70) return "C";
|
||||
if (percentage >= 60) return "D";
|
||||
return "F";
|
||||
}
|
||||
case "percentage":
|
||||
default:
|
||||
return `${percentage.toFixed(decimals)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for grade based on system
|
||||
*/
|
||||
export function getGradeColorBySystem(
|
||||
percentage: number,
|
||||
system: GradeSystem
|
||||
): string {
|
||||
switch (system) {
|
||||
case "german": {
|
||||
const germanGrade = percentageToGermanGradeDetailed(percentage);
|
||||
return getGermanGradeColor(germanGrade);
|
||||
}
|
||||
case "percentage":
|
||||
case "us-letter":
|
||||
default:
|
||||
if (percentage >= 90) return "text-green-600";
|
||||
if (percentage >= 80) return "text-blue-600";
|
||||
if (percentage >= 70) return "text-yellow-600";
|
||||
if (percentage >= 60) return "text-orange-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description for grade
|
||||
*/
|
||||
export function getGradeDescription(
|
||||
percentage: number,
|
||||
system: GradeSystem
|
||||
): string | null {
|
||||
if (system === "german") {
|
||||
const germanGrade = percentageToGermanGradeDetailed(percentage);
|
||||
return getGermanGradeDescription(germanGrade);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user