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