first commit

This commit is contained in:
Space
2026-01-17 13:37:57 +01:00
commit 3e34d84a29
49 changed files with 8579 additions and 0 deletions

View 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>
);
}