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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
|
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
|
||||||
@@ -65,11 +69,35 @@ function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDra
|
|||||||
onDrop={(e) => { e.preventDefault(); onDrop(); }}
|
onDrop={(e) => { e.preventDefault(); onDrop(); }}
|
||||||
onClick={onClickAssign}
|
onClick={onClickAssign}
|
||||||
className={`
|
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'}
|
${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]">
|
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||||
{notes.length === 0 && (
|
{notes.length === 0 && (
|
||||||
<span className="text-xs text-gray-400 italic">Drop here</span>
|
<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 [draggedNote, setDraggedNote] = useState(null);
|
||||||
const [overBucket, setOverBucket] = useState(null);
|
const [overBucket, setOverBucket] = useState(null);
|
||||||
const [newCatName, setNewCatName] = useState('');
|
const [newCatName, setNewCatName] = useState('');
|
||||||
|
const [newCatType, setNewCatType] = useState('note');
|
||||||
const [showNewCat, setShowNewCat] = useState(false);
|
const [showNewCat, setShowNewCat] = useState(false);
|
||||||
|
|
||||||
// Derived from buckets (recomputed every render — cheap)
|
// 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: [] }]);
|
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 unassignedNotes = uniqueNotes.filter(n => !classifications[n]);
|
||||||
const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0;
|
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">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{buckets.map((b) => {
|
{buckets.map((b) => {
|
||||||
const colors = getCategoryColors(b.name, customCategories);
|
const colors = getCategoryColors(b.name, customCategories);
|
||||||
|
const isCustom = !DEFAULT_BUCKET_NAMES.has(b.name);
|
||||||
return (
|
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}
|
isOver={overBucket === b.name}
|
||||||
|
isCustom={isCustom}
|
||||||
|
unassignedCount={unassignedNotes.length}
|
||||||
onDragOver={() => setOverBucket(b.name)}
|
onDragOver={() => setOverBucket(b.name)}
|
||||||
onDragLeave={() => setOverBucket(null)}
|
onDragLeave={() => setOverBucket(null)}
|
||||||
onDrop={() => {
|
onDrop={() => {
|
||||||
@@ -442,26 +467,50 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
|||||||
}}
|
}}
|
||||||
onClickAssign={() => { if (selectedNote) assign(selectedNote, b.name); }}
|
onClickAssign={() => { if (selectedNote) assign(selectedNote, b.name); }}
|
||||||
onRemoveNote={(note) => unassign(note)}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Add new category */}
|
{/* Add new bucket — note or numerical */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{showNewCat ? (
|
{showNewCat ? (
|
||||||
<>
|
<>
|
||||||
<input autoFocus value={newCatName} onChange={(e) => setNewCatName(e.target.value)}
|
<input
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowNewCat(false); }}
|
autoFocus
|
||||||
placeholder="Category name…"
|
value={newCatName}
|
||||||
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40" />
|
onChange={(e) => setNewCatName(e.target.value)}
|
||||||
<Button size="sm" type="button" onClick={addCategory}>Add</Button>
|
onKeyDown={(e) => { if (e.key === 'Escape') setShowNewCat(false); }}
|
||||||
<button type="button" onClick={() => setShowNewCat(false)} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
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)}
|
<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">
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user