feat(frontend): add field picker + numerical bucket UI in classify phase
Replaces "+ New category" with a Note/Numerical type-aware picker, extends CategoryBucket to render type badges, bulk-add, and custom-bucket remove controls, and deletes the addCategory() back-compat shim from Task 7.
This commit is contained in:
@@ -57,7 +57,11 @@ function NoteChip({ value, category, colors, selected, onSelect, draggable, onDr
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign }) {
|
||||
function CategoryBucket({
|
||||
name, type, notes, colors, isOver, isCustom, unassignedCount,
|
||||
onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign,
|
||||
onBulkAddUnassigned, onRemoveBucket,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
|
||||
@@ -65,11 +69,35 @@ function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDra
|
||||
onDrop={(e) => { e.preventDefault(); onDrop(); }}
|
||||
onClick={onClickAssign}
|
||||
className={`
|
||||
rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer
|
||||
relative group rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer
|
||||
${isOver ? `${colors.border} ${colors.bg} scale-[1.02]` : 'border-gray-200 hover:border-gray-300'}
|
||||
`}
|
||||
>
|
||||
<p className={`text-xs font-semibold uppercase tracking-wide mb-2 ${colors.text}`}>{name}</p>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className={`text-xs font-semibold uppercase tracking-wide ${colors.text}`}>
|
||||
{name}
|
||||
{type === 'numerical' && (
|
||||
<span className="ml-2 text-[9px] font-bold uppercase bg-white/60 px-1 py-0.5 rounded border border-current/20">
|
||||
numerical
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{isCustom && (
|
||||
<button type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemoveBucket(); }}
|
||||
className="text-[10px] text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove this field">✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'numerical' && unassignedCount > 0 && (
|
||||
<button type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onBulkAddUnassigned(); }}
|
||||
className={`text-[10px] mb-2 px-2 py-0.5 rounded border ${colors.border} ${colors.text} hover:bg-white transition-colors`}>
|
||||
+ Add all {unassignedCount} unassigned
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||
{notes.length === 0 && (
|
||||
<span className="text-xs text-gray-400 italic">Drop here</span>
|
||||
@@ -116,6 +144,7 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
||||
const [draggedNote, setDraggedNote] = useState(null);
|
||||
const [overBucket, setOverBucket] = useState(null);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
const [newCatType, setNewCatType] = useState('note');
|
||||
const [showNewCat, setShowNewCat] = useState(false);
|
||||
|
||||
// Derived from buckets (recomputed every render — cheap)
|
||||
@@ -211,13 +240,6 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
||||
setBuckets((prev) => [...prev, { name: trimmed, type, notes: [] }]);
|
||||
}
|
||||
|
||||
// Back-compat shim so the existing "+ New category" UI keeps working until Task 9 swaps the picker
|
||||
function addCategory() {
|
||||
addBucket(newCatName, 'note');
|
||||
setNewCatName('');
|
||||
setShowNewCat(false);
|
||||
}
|
||||
|
||||
const unassignedNotes = uniqueNotes.filter(n => !classifications[n]);
|
||||
const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0;
|
||||
|
||||
@@ -432,9 +454,12 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{buckets.map((b) => {
|
||||
const colors = getCategoryColors(b.name, customCategories);
|
||||
const isCustom = !DEFAULT_BUCKET_NAMES.has(b.name);
|
||||
return (
|
||||
<CategoryBucket key={b.name} name={b.name} notes={b.notes} colors={colors}
|
||||
<CategoryBucket key={b.name} name={b.name} type={b.type} notes={b.notes} colors={colors}
|
||||
isOver={overBucket === b.name}
|
||||
isCustom={isCustom}
|
||||
unassignedCount={unassignedNotes.length}
|
||||
onDragOver={() => setOverBucket(b.name)}
|
||||
onDragLeave={() => setOverBucket(null)}
|
||||
onDrop={() => {
|
||||
@@ -442,26 +467,50 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
||||
}}
|
||||
onClickAssign={() => { if (selectedNote) assign(selectedNote, b.name); }}
|
||||
onRemoveNote={(note) => unassign(note)}
|
||||
onBulkAddUnassigned={() => {
|
||||
for (const n of unassignedNotes) assign(n, b.name);
|
||||
}}
|
||||
onRemoveBucket={() => setBuckets((prev) => prev.filter((x) => x.name !== b.name))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add new category */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Add new bucket — note or numerical */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{showNewCat ? (
|
||||
<>
|
||||
<input autoFocus value={newCatName} onChange={(e) => setNewCatName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowNewCat(false); }}
|
||||
placeholder="Category name…"
|
||||
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40" />
|
||||
<Button size="sm" type="button" onClick={addCategory}>Add</Button>
|
||||
<button type="button" onClick={() => setShowNewCat(false)} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
||||
<input
|
||||
autoFocus
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setShowNewCat(false); }}
|
||||
placeholder="Field name…"
|
||||
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40"
|
||||
/>
|
||||
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
|
||||
<input type="radio" name="newBucketType" value="note"
|
||||
checked={newCatType === 'note'}
|
||||
onChange={() => setNewCatType('note')} /> Note
|
||||
</label>
|
||||
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
|
||||
<input type="radio" name="newBucketType" value="numerical"
|
||||
checked={newCatType === 'numerical'}
|
||||
onChange={() => setNewCatType('numerical')} /> Numerical
|
||||
</label>
|
||||
<Button size="sm" type="button" onClick={() => {
|
||||
addBucket(newCatName, newCatType);
|
||||
setNewCatName('');
|
||||
setNewCatType('note');
|
||||
setShowNewCat(false);
|
||||
}}>Add</Button>
|
||||
<button type="button" onClick={() => setShowNewCat(false)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowNewCat(true)}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700 border border-dashed border-indigo-300 rounded-full px-3 py-0.5 hover:border-indigo-500 transition-colors">
|
||||
+ New category
|
||||
+ Add field
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user