first commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user