fix(pipelines): size columns to fill, batch stage saves, inline delete confirm

Sizing:
- Board columns grow to fill wide viewports (flex-1 basis-[260px])
  instead of being capped at 300px; still overflow-scroll on narrow screens.
- Analytics grid: 2 cols mobile → 3 cols sm → 6 cols xl (was lg, which
  cramped labels on 1024-1280px widths).

Senior-review pass:
- pipeline-settings handleSave: replace N sequential UPDATEs in a for-loop
  with a single upsert (onConflict: "id"). Previously latency scaled
  linearly with stage count; now constant.
- deal-form delete: swap window.confirm for an inline confirm row that
  matches the app's pattern (pipeline-settings uses the same inline
  confirmation for pipeline deletion).
- pipeline-board: add KeyboardSensor alongside PointerSensor so deal cards
  are draggable via keyboard for a11y (Space to pick up, arrows to move).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-16 22:59:28 +03:00
parent 4229c94ec5
commit 03fbead9c8
No known key found for this signature in database
4 changed files with 66 additions and 34 deletions

View File

@ -69,10 +69,12 @@ export function DealForm({
const [saving, setSaving] = useState(false);
const [statusAction, setStatusAction] = useState<DealStatus | null>(null);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
// Reset when opening
useEffect(() => {
if (!open) return;
setConfirmDelete(false);
if (deal) {
setTitle(deal.title);
setValue(String(deal.value ?? ""));
@ -211,10 +213,6 @@ export function DealForm({
async function handleDelete() {
if (!deal) return;
const confirmed = window.confirm(
"Delete this deal? This cannot be undone.",
);
if (!confirmed) return;
setDeleting(true);
const { error } = await supabase.from("deals").delete().eq("id", deal.id);
setDeleting(false);
@ -223,6 +221,7 @@ export function DealForm({
return;
}
toast.success("Deal deleted");
setConfirmDelete(false);
onOpenChange(false);
onSaved();
}
@ -426,17 +425,39 @@ export function DealForm({
</Button>
</div>
{deal && (
{deal &&
(confirmDelete ? (
<div className="mt-3 flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs">
<span className="text-red-300">Delete this deal?</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="rounded px-2 py-1 text-slate-300 hover:bg-slate-800"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? "Deleting..." : "Confirm"}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
{deleting ? "Deleting..." : "Delete Deal"}
Delete Deal
</button>
)}
))}
</div>
</div>
</SheetContent>

View File

@ -99,7 +99,7 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
return (
<TooltipProvider>
<div className="grid grid-cols-2 gap-3 rounded-xl border border-slate-800 bg-slate-900/60 p-4 sm:grid-cols-3 lg:grid-cols-6">
<div className="grid grid-cols-2 gap-3 rounded-xl border border-slate-800 bg-slate-900/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
<Metric
icon={<BarChart3 className="h-4 w-4 text-slate-400" />}
label="Total Deals"

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
@ -62,6 +63,9 @@ export function PipelineBoard({
const sensors = useSensors(
// 5px activation distance avoids clicks being interpreted as drags.
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
// Keyboard drag support: focus a card, Space to pick up, arrows to move,
// Space to drop, Escape to cancel.
useSensor(KeyboardSensor),
);
const activeDeal = activeDealId
@ -172,7 +176,7 @@ function StageColumn({
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return (
<div className="flex min-w-[280px] max-w-[300px] shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div className="flex min-w-[260px] flex-1 basis-[260px] flex-col rounded-xl border border-slate-800 bg-slate-900/60 p-4">
{/* 3px colored top border — sits above the column's padding */}
<div
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"

View File

@ -100,26 +100,33 @@ export function PipelineSettings({
async function handleSave() {
setSaving(true);
const { error: renameErr } = await supabase
// One upsert for all stages — batches N stage writes into a single
// round-trip. Previous implementation did N sequential UPDATEs which
// latency-scaled linearly with stage count.
const stageRows = localStages.map((s, i) => ({
id: s.id,
pipeline_id: s.pipeline_id,
name: s.name,
color: s.color,
position: i,
}));
const [renameRes, stagesRes] = await Promise.all([
supabase
.from("pipelines")
.update({ name: name.trim() })
.eq("id", pipeline.id);
if (renameErr) {
toast.error("Failed to rename pipeline");
.eq("id", pipeline.id),
supabase.from("pipeline_stages").upsert(stageRows, { onConflict: "id" }),
]);
setSaving(false);
if (renameRes.error || stagesRes.error) {
toast.error("Failed to save pipeline");
return;
}
// Persist each stage's name/color/position
for (let i = 0; i < localStages.length; i++) {
const s = localStages[i];
await supabase
.from("pipeline_stages")
.update({ name: s.name, color: s.color, position: i })
.eq("id", s.id);
}
setSaving(false);
onOpenChange(false);
onPipelinesChanged();
onStagesChanged();