143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|