Files
grademaxxing/app/routes/subjects.$subjectId.edit.tsx
2026-01-17 13:37:57 +01:00

306 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}