feat(pipelines): Kanban drag-drop, analytics, deal sheet, contact deals tab

Rebuild the Pipelines page to match spec.

Highlights:
- Drag-and-drop deal cards between stages via @dnd-kit (200ms drop animation,
  emerald dashed drop zone, opacity fade while dragging)
- First-visit seed: auto-creates "Sales Pipeline" with spec-defined stages
  (New Lead / Qualified / Proposal Sent / Negotiation / Won)
- Top bar: always-visible pipeline dropdown with "Manage Pipelines" option,
  plus "Add Pipeline" and emerald "Add Deal" buttons
- Deal form converted from Dialog to right-side Sheet, adds assigned_to
  (profile FK), "Link to Conversation" shortcut, "Mark as Won/Lost" status
  buttons, and "Delete Deal"
- Pipeline settings modal: drag-to-reorder stages (@dnd-kit sortable),
  per-stage color picker, "Create new pipeline" shortcut, archive
  confirmation copy
- Analytics bar below board: total deals, pipeline value, avg deal size,
  weighted pipeline value (probability interpolated across stages),
  won/lost this month
- Contact detail sheet gains a "Deals" tab showing all deals for that
  contact (value + stage badge + status)
- Migration 002: adds deals.assigned_to FK→profiles, normalizes status
  CHECK to ('open','won','lost')

Note: spec called for light-gray Kanban columns (#f9fafb) + white cards
inside an otherwise-dark app. Followed spec literally; easy to retheme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-16 22:38:45 +03:00
parent 3615e8cacf
commit f1eafd5d27
No known key found for this signature in database
11 changed files with 1549 additions and 683 deletions

56
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.10.2", "@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.103.3", "@supabase/supabase-js": "^2.103.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -522,6 +525,59 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.61.0", "version": "1.61.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz",

View File

@ -10,6 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.10.2", "@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.103.3", "@supabase/supabase-js": "^2.103.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -1,10 +1,12 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { Pipeline, PipelineStage } from "@/types"; import type { Pipeline, PipelineStage, Deal } from "@/types";
import { PipelineBoard } from "@/components/pipelines/pipeline-board"; import { PipelineBoard } from "@/components/pipelines/pipeline-board";
import { PipelineSettings } from "@/components/pipelines/pipeline-settings"; import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
import { DealForm } from "@/components/pipelines/deal-form";
import { PipelineAnalytics } from "@/components/pipelines/pipeline-analytics";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -13,75 +15,221 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
GitBranch, import { toast } from "sonner";
Plus,
Settings,
ChevronDown,
} from "lucide-react";
const DEFAULT_STAGES = [ // Spec-defined seed — name and color per the product spec.
{ name: "Lead", color: "#6366f1", position: 0 }, const SPEC_DEFAULT_STAGES = [
{ name: "Qualified", color: "#8b5cf6", position: 1 }, { name: "New Lead", color: "#3b82f6", position: 0 }, // blue
{ name: "Proposal", color: "#f97316", position: 2 }, { name: "Qualified", color: "#eab308", position: 1 }, // yellow
{ name: "Negotiation", color: "#eab308", position: 3 }, { name: "Proposal Sent", color: "#f97316", position: 2 }, // orange
{ name: "Won", color: "#22c55e", position: 4 }, { name: "Negotiation", color: "#8b5cf6", position: 3 }, // purple
{ name: "Won", color: "#22c55e", position: 4 }, // green
]; ];
export default function PipelinesPage() { export default function PipelinesPage() {
const supabase = createClient();
const [pipelines, setPipelines] = useState<Pipeline[]>([]); const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [stages, setStages] = useState<PipelineStage[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState<string>(""); const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
const [stages, setStages] = useState<PipelineStage[]>([]);
const [deals, setDeals] = useState<Deal[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Dialog / sheet state
const [newPipelineOpen, setNewPipelineOpen] = useState(false); const [newPipelineOpen, setNewPipelineOpen] = useState(false);
const [newPipelineName, setNewPipelineName] = useState(""); const [newPipelineName, setNewPipelineName] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const supabase = createClient(); // Deal form state is lifted here so both the top-bar "Add Deal" and
// the per-column "+" trigger the same Sheet.
const [dealFormOpen, setDealFormOpen] = useState(false);
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
const [defaultStageId, setDefaultStageId] = useState<string>("");
useEffect(() => { // Guard against double-seeding (React StrictMode double-effect in dev).
loadPipelines(); const seedAttempted = useRef(false);
}, []);
useEffect(() => { const loadPipelines = useCallback(async () => {
if (selectedPipelineId) { const { data, error } = await supabase
loadStages(selectedPipelineId);
}
}, [selectedPipelineId]);
async function loadPipelines() {
setLoading(true);
const { data } = await supabase
.from("pipelines") .from("pipelines")
.select("*") .select("*")
.order("created_at"); .order("created_at");
if (data && data.length > 0) { if (error) {
setPipelines(data); console.error("Failed to load pipelines:", error.message);
if (!selectedPipelineId || !data.find((p) => p.id === selectedPipelineId)) { return [];
setSelectedPipelineId(data[0].id);
}
} else {
setPipelines([]);
setSelectedPipelineId("");
}
setLoading(false);
} }
return data ?? [];
}, [supabase]);
async function loadStages(pipelineId: string) { const loadStages = useCallback(
async (pipelineId: string) => {
const { data } = await supabase const { data } = await supabase
.from("pipeline_stages") .from("pipeline_stages")
.select("*") .select("*")
.eq("pipeline_id", pipelineId) .eq("pipeline_id", pipelineId)
.order("position"); .order("position");
if (data) setStages(data); return data ?? [];
},
[supabase],
);
const loadDeals = useCallback(
async (pipelineId: string) => {
const { data } = await supabase
.from("deals")
.select("*, contact:contacts(*), assignee:profiles!deals_assigned_to_fkey(*)")
.eq("pipeline_id", pipelineId)
.order("created_at", { ascending: false });
return (data ?? []) as Deal[];
},
[supabase],
);
const seedDefaultPipeline = useCallback(async (): Promise<Pipeline | null> => {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) return null;
const { data: pipeline, error } = await supabase
.from("pipelines")
.insert({ user_id: user.id, name: "Sales Pipeline" })
.select()
.single();
if (error || !pipeline) {
console.error("Failed to seed pipeline:", error?.message);
return null;
} }
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
pipeline_id: pipeline.id,
name: s.name,
color: s.color,
position: s.position,
}));
await supabase.from("pipeline_stages").insert(stagesPayload);
return pipeline as Pipeline;
}, [supabase]);
// Initial load + seed-if-empty
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
let list = await loadPipelines();
if (list.length === 0 && !seedAttempted.current) {
seedAttempted.current = true;
const seeded = await seedDefaultPipeline();
if (seeded) list = await loadPipelines();
}
if (cancelled) return;
setPipelines(list);
if (list.length > 0) {
setSelectedPipelineId((prev) =>
prev && list.some((p) => p.id === prev) ? prev : list[0].id,
);
} else {
setSelectedPipelineId("");
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [loadPipelines, seedDefaultPipeline]);
// Load stages + deals whenever selected pipeline changes
useEffect(() => {
if (!selectedPipelineId) {
setStages([]);
setDeals([]);
return;
}
let cancelled = false;
(async () => {
const [s, d] = await Promise.all([
loadStages(selectedPipelineId),
loadDeals(selectedPipelineId),
]);
if (cancelled) return;
setStages(s);
setDeals(d);
})();
return () => {
cancelled = true;
};
}, [selectedPipelineId, loadStages, loadDeals]);
const refreshPipelines = useCallback(async () => {
const list = await loadPipelines();
setPipelines(list);
if (list.length === 0) setSelectedPipelineId("");
else if (!list.some((p) => p.id === selectedPipelineId))
setSelectedPipelineId(list[0].id);
}, [loadPipelines, selectedPipelineId]);
const refreshStages = useCallback(async () => {
if (!selectedPipelineId) return;
setStages(await loadStages(selectedPipelineId));
}, [loadStages, selectedPipelineId]);
const refreshDeals = useCallback(async () => {
if (!selectedPipelineId) return;
setDeals(await loadDeals(selectedPipelineId));
}, [loadDeals, selectedPipelineId]);
const handleDealMoved = useCallback(
async (dealId: string, newStageId: string) => {
// Optimistic update — board already animated; just persist.
setDeals((prev) =>
prev.map((d) => (d.id === dealId ? { ...d, stage_id: newStageId } : d)),
);
const { error } = await supabase
.from("deals")
.update({ stage_id: newStageId })
.eq("id", dealId);
if (error) {
toast.error("Failed to move deal");
refreshDeals();
}
},
[supabase, refreshDeals],
);
const handleAddDeal = useCallback(
(stageId?: string) => {
setEditingDeal(null);
setDefaultStageId(stageId ?? stages[0]?.id ?? "");
setDealFormOpen(true);
},
[stages],
);
const handleEditDeal = useCallback((deal: Deal) => {
setEditingDeal(deal);
setDefaultStageId(deal.stage_id);
setDealFormOpen(true);
}, []);
async function handleCreatePipeline() { async function handleCreatePipeline() {
if (!newPipelineName.trim()) return; const name = newPipelineName.trim();
if (!name) return;
setCreating(true); setCreating(true);
const { const {
@ -93,35 +241,32 @@ export default function PipelinesPage() {
return; return;
} }
const { data: pipeline } = await supabase const { data: pipeline, error } = await supabase
.from("pipelines") .from("pipelines")
.insert({ user_id: user.id, name: newPipelineName.trim() }) .insert({ user_id: user.id, name })
.select() .select()
.single(); .single();
if (pipeline) { if (error || !pipeline) {
const stageInserts = DEFAULT_STAGES.map((s) => ({ toast.error("Failed to create pipeline");
setCreating(false);
return;
}
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
pipeline_id: pipeline.id, pipeline_id: pipeline.id,
name: s.name, name: s.name,
color: s.color, color: s.color,
position: s.position, position: s.position,
})); }));
await supabase.from("pipeline_stages").insert(stageInserts); await supabase.from("pipeline_stages").insert(stagesPayload);
setNewPipelineName(""); setNewPipelineName("");
setNewPipelineOpen(false); setNewPipelineOpen(false);
setSelectedPipelineId(pipeline.id); setSelectedPipelineId(pipeline.id);
await loadPipelines(); await refreshPipelines();
}
setCreating(false); setCreating(false);
} toast.success("Pipeline created");
function handleRefresh() {
loadPipelines();
if (selectedPipelineId) {
loadStages(selectedPipelineId);
}
} }
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId); const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
@ -130,18 +275,12 @@ export default function PipelinesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="h-8 w-48 animate-pulse rounded bg-slate-800" />
<div className="h-7 w-32 animate-pulse rounded bg-slate-800" /> <div className="h-9 w-28 animate-pulse rounded-lg bg-slate-800" />
<div className="mt-2 h-4 w-48 animate-pulse rounded bg-slate-800" />
</div> </div>
<div className="h-8 w-28 animate-pulse rounded-lg bg-slate-800" /> <div className="flex gap-3">
</div> {[1, 2, 3, 4, 5].map((i) => (
<div className="flex gap-4"> <div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50" />
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50"
/>
))} ))}
</div> </div>
</div> </div>
@ -151,76 +290,77 @@ export default function PipelinesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">Pipelines</h1> {/* Pipeline selector dropdown */}
<p className="mt-1 text-sm text-slate-400"> <DropdownMenu>
Manage your deals and sales pipeline <DropdownMenuTrigger
</p> className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors data-[popup-open]:bg-slate-800"
</div>
<div className="flex items-center gap-2">
{selectedPipeline && (
<Button
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
className="text-slate-400 hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
)}
<Button
onClick={() => setNewPipelineOpen(true)}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
<Plus className="h-4 w-4 mr-1" />
New Pipeline
</Button>
</div>
</div>
{/* Pipeline selector */}
{pipelines.length > 1 && (
<div className="relative">
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors"
> >
<GitBranch className="h-4 w-4 text-emerald-500" /> <GitBranch className="h-4 w-4 text-emerald-500" />
{selectedPipeline?.name || "Select Pipeline"} <span className="font-semibold">
{selectedPipeline?.name ?? "Select Pipeline"}
</span>
<ChevronDown className="h-4 w-4 text-slate-400" /> <ChevronDown className="h-4 w-4 text-slate-400" />
</button> </DropdownMenuTrigger>
{dropdownOpen && ( <DropdownMenuContent
<> align="start"
<div className="w-64 border-slate-700 bg-slate-900 text-slate-200"
className="fixed inset-0 z-10"
onClick={() => setDropdownOpen(false)}
/>
<div className="absolute top-full left-0 z-20 mt-1 w-56 rounded-lg border border-slate-700 bg-slate-900 py-1 shadow-lg">
{pipelines.map((p) => (
<button
key={p.id}
onClick={() => {
setSelectedPipelineId(p.id);
setDropdownOpen(false);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors ${
p.id === selectedPipelineId
? "bg-emerald-500/10 text-emerald-400"
: "text-slate-300 hover:bg-slate-800"
}`}
> >
<GitBranch className="h-3.5 w-3.5" /> {pipelines.length === 0 && (
<DropdownMenuItem disabled className="text-slate-500">
No pipelines yet
</DropdownMenuItem>
)}
{pipelines.map((p) => (
<DropdownMenuItem
key={p.id}
onClick={() => setSelectedPipelineId(p.id)}
className={
p.id === selectedPipelineId
? "text-emerald-400"
: "text-slate-300"
}
>
<GitBranch className="mr-2 h-3.5 w-3.5" />
{p.name} {p.name}
</button> </DropdownMenuItem>
))} ))}
</div> <DropdownMenuSeparator className="bg-slate-700" />
</> {selectedPipeline && (
<DropdownMenuItem
onClick={() => setSettingsOpen(true)}
className="text-slate-300"
>
<Settings className="mr-2 h-3.5 w-3.5" />
Manage Pipelines
</DropdownMenuItem>
)} )}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
)}
{/* Board or empty state */} <div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setNewPipelineOpen(true)}
className="border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800"
>
<Plus className="mr-1 h-4 w-4" />
Add Pipeline
</Button>
<Button
onClick={() => handleAddDeal()}
disabled={!selectedPipelineId || stages.length === 0}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
<Plus className="mr-1 h-4 w-4" />
Add Deal
</Button>
</div>
</div>
{/* Board */}
{pipelines.length === 0 ? ( {pipelines.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20">
<GitBranch className="h-12 w-12 text-slate-600" /> <GitBranch className="h-12 w-12 text-slate-600" />
@ -228,22 +368,27 @@ export default function PipelinesPage() {
No pipelines yet No pipelines yet
</h3> </h3>
<p className="mt-2 text-sm text-slate-400"> <p className="mt-2 text-sm text-slate-400">
Create your first pipeline to start tracking deals Create a pipeline to start tracking deals
</p> </p>
<Button <Button
onClick={() => setNewPipelineOpen(true)} onClick={() => setNewPipelineOpen(true)}
className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700" className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700"
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="mr-1 h-4 w-4" />
Create Pipeline Create Pipeline
</Button> </Button>
</div> </div>
) : ( ) : (
<>
<PipelineBoard <PipelineBoard
pipelineId={selectedPipelineId}
stages={stages} stages={stages}
onStagesChange={() => loadStages(selectedPipelineId)} deals={deals}
onDealMoved={handleDealMoved}
onAddDeal={handleAddDeal}
onEditDeal={handleEditDeal}
/> />
<PipelineAnalytics stages={stages} deals={deals} />
</>
)} )}
{/* New Pipeline Dialog */} {/* New Pipeline Dialog */}
@ -257,15 +402,14 @@ export default function PipelinesPage() {
<Input <Input
value={newPipelineName} value={newPipelineName}
onChange={(e) => setNewPipelineName(e.target.value)} onChange={(e) => setNewPipelineName(e.target.value)}
placeholder="e.g., Sales Pipeline" placeholder="e.g., Enterprise Sales"
className="mt-2 bg-slate-800 border-slate-700 text-white" className="mt-2 bg-slate-800 border-slate-700 text-white"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleCreatePipeline(); if (e.key === "Enter") handleCreatePipeline();
}} }}
/> />
<p className="mt-2 text-xs text-slate-400"> <p className="mt-2 text-xs text-slate-400">
Default stages (Lead, Qualified, Proposal, Negotiation, Won) will Default stages (New Lead → Won) will be created automatically.
be created automatically.
</p> </p>
</div> </div>
<DialogFooter className="bg-slate-900/50 border-slate-700"> <DialogFooter className="bg-slate-900/50 border-slate-700">
@ -294,9 +438,25 @@ export default function PipelinesPage() {
onOpenChange={setSettingsOpen} onOpenChange={setSettingsOpen}
pipeline={selectedPipeline} pipeline={selectedPipeline}
stages={stages} stages={stages}
onUpdated={handleRefresh} onPipelinesChanged={refreshPipelines}
onStagesChanged={refreshStages}
onCreateNewPipeline={() => {
setSettingsOpen(false);
setNewPipelineOpen(true);
}}
/> />
)} )}
{/* Deal Form (Sheet) */}
<DealForm
open={dealFormOpen}
onOpenChange={setDealFormOpen}
deal={editingDeal}
pipelineId={selectedPipelineId}
stages={stages}
defaultStageId={defaultStageId}
onSaved={refreshDeals}
/>
</div> </div>
); );
} }

View File

@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue } from '@/types'; import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -30,6 +30,7 @@ import {
Trash2, Trash2,
Save, Save,
X, X,
DollarSign,
} from 'lucide-react'; } from 'lucide-react';
interface ContactDetailViewProps { interface ContactDetailViewProps {
@ -75,6 +76,10 @@ export function ContactDetailView({
const [savingCustom, setSavingCustom] = useState(false); const [savingCustom, setSavingCustom] = useState(false);
const [loadingCustom, setLoadingCustom] = useState(false); const [loadingCustom, setLoadingCustom] = useState(false);
// Deals tab
const [deals, setDeals] = useState<Deal[]>([]);
const [loadingDeals, setLoadingDeals] = useState(false);
const fetchContact = useCallback(async () => { const fetchContact = useCallback(async () => {
if (!contactId) return; if (!contactId) return;
setLoading(true); setLoading(true);
@ -146,14 +151,27 @@ export function ContactDetailView({
setLoadingCustom(false); setLoadingCustom(false);
}, [contactId, supabase]); }, [contactId, supabase]);
const fetchDeals = useCallback(async () => {
if (!contactId) return;
setLoadingDeals(true);
const { data } = await supabase
.from('deals')
.select('*, stage:pipeline_stages(*)')
.eq('contact_id', contactId)
.order('created_at', { ascending: false });
setDeals((data ?? []) as Deal[]);
setLoadingDeals(false);
}, [contactId, supabase]);
useEffect(() => { useEffect(() => {
if (open && contactId) { if (open && contactId) {
fetchContact(); fetchContact();
fetchTags(); fetchTags();
fetchNotes(); fetchNotes();
fetchCustomFields(); fetchCustomFields();
fetchDeals();
} }
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields]); }, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields, fetchDeals]);
async function copyPhone() { async function copyPhone() {
if (!contact) return; if (!contact) return;
@ -389,6 +407,12 @@ export function ContactDetailView({
> >
Custom Fields Custom Fields
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="deals"
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
>
Deals
</TabsTrigger>
</TabsList> </TabsList>
{/* Details Tab */} {/* Details Tab */}
@ -594,6 +618,64 @@ export function ContactDetailView({
</div> </div>
)} )}
</TabsContent> </TabsContent>
{/* Deals Tab */}
<TabsContent value="deals" className="flex-1 overflow-y-auto px-4 py-3">
{loadingDeals ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-emerald-500" />
</div>
) : deals.length === 0 ? (
<p className="text-xs text-slate-500">No deals yet</p>
) : (
<div className="space-y-2">
{deals.map((deal) => (
<div
key={deal.id}
className="rounded-lg border border-slate-700 bg-slate-800/50 p-3"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-white">
{deal.title}
</p>
{deal.stage && (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `${deal.stage.color}20`,
color: deal.stage.color,
}}
>
{deal.stage.name}
</span>
)}
</div>
<div className="mt-1.5 flex items-center justify-between text-xs text-slate-400">
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: deal.currency || 'USD',
maximumFractionDigits: 0,
}).format(Number(deal.value || 0))}
</span>
{deal.status && deal.status !== 'open' && (
<span
className={
deal.status === 'won'
? 'text-emerald-400'
: 'text-red-400'
}
>
{deal.status}
</span>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs> </Tabs>
</div> </div>
)} )}

View File

@ -1,24 +1,22 @@
"use client"; "use client";
import { Deal } from "@/types"; import type { Deal, PipelineStage } from "@/types";
import { Badge } from "@/components/ui/badge"; import { Calendar, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { interface DealCardProps {
ChevronLeft, deal: Deal;
ChevronRight, stage: PipelineStage | null;
Calendar, onEdit: (deal: Deal) => void;
User, isOverlay?: boolean;
DollarSign, }
} from "lucide-react";
function formatCurrency(value: number, currency?: string) { function formatCurrency(value: number, currency?: string) {
const cur = currency || "USD";
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: cur, currency: currency || "USD",
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(value); }).format(Number(value || 0));
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
@ -29,95 +27,87 @@ function formatDate(dateStr: string) {
}); });
} }
interface DealCardProps { function initials(name?: string, fallback?: string) {
deal: Deal; const source = (name || fallback || "?").trim();
onEdit: (deal: Deal) => void; if (!source) return "?";
onMoveStage: (dealId: string, direction: "prev" | "next") => void; return source.charAt(0).toUpperCase();
hasPrev: boolean;
hasNext: boolean;
} }
export function DealCard({ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
deal, const contactLabel = deal.contact?.name || deal.contact?.phone || "No contact";
onEdit, const assigneeLabel = deal.assignee?.full_name || null;
onMoveStage,
hasPrev,
hasNext,
}: DealCardProps) {
const statusColor =
deal.status === "won"
? "text-emerald-400"
: deal.status === "lost"
? "text-red-400"
: "text-slate-400";
return ( return (
<div <button
onClick={() => onEdit(deal)} type="button"
className="group cursor-pointer rounded-lg border border-slate-700/50 bg-slate-800/50 p-3 transition-all hover:border-slate-600 hover:bg-slate-800" onClick={(e) => {
// `onClick` still fires after a non-drag tap because the PointerSensor
// requires 5px movement before it counts as a drag.
if (isOverlay) return;
e.stopPropagation();
onEdit(deal);
}}
className={`group relative w-full cursor-pointer rounded-xl bg-white pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
isOverlay
? "shadow-xl"
: "hover:-translate-y-0.5 hover:shadow-md"
}`}
> >
<div className="mb-2 flex items-start justify-between gap-2"> {/* 4px left accent bar using stage color */}
<h4 className="text-sm font-medium text-white leading-snug truncate"> <span
aria-hidden
className="absolute left-0 top-0 h-full w-1 rounded-l-xl"
style={{ backgroundColor: stage?.color ?? "#94a3b8" }}
/>
<div className="flex items-start justify-between gap-2">
<h4 className="flex-1 text-sm font-semibold leading-snug text-gray-900 break-words">
{deal.title} {deal.title}
</h4> </h4>
{deal.status && deal.status !== "open" && ( {deal.status === "won" && (
<Badge variant="outline" className={statusColor}> <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700">
{deal.status} <Check className="h-3 w-3" />
</Badge> Won
</span>
)}
{deal.status === "lost" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-700">
<X className="h-3 w-3" />
Lost
</span>
)} )}
</div> </div>
<div className="mb-2 flex items-center gap-1.5"> {/* Contact row */}
<DollarSign className="h-3.5 w-3.5 text-emerald-400" /> <div className="mt-2 flex items-center gap-2">
<span className="text-sm font-semibold text-emerald-400"> <span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-200 text-[10px] font-semibold text-slate-700">
{initials(deal.contact?.name, deal.contact?.phone)}
</span>
<span className="truncate text-xs text-gray-600">{contactLabel}</span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-sm font-bold text-gray-900">
{formatCurrency(deal.value, deal.currency)} {formatCurrency(deal.value, deal.currency)}
</span> </span>
</div>
{deal.contact && (
<div className="mb-1.5 flex items-center gap-1.5">
<User className="h-3 w-3 text-slate-500" />
<span className="text-xs text-slate-400 truncate">
{deal.contact.name || deal.contact.phone}
</span>
</div>
)}
{deal.expected_close_date && ( {deal.expected_close_date && (
<div className="mb-2 flex items-center gap-1.5"> <span className="flex items-center gap-1 text-[11px] text-gray-500">
<Calendar className="h-3 w-3 text-slate-500" /> <Calendar className="h-3 w-3" />
<span className="text-xs text-slate-400">
{formatDate(deal.expected_close_date)} {formatDate(deal.expected_close_date)}
</span> </span>
)}
</div>
{assigneeLabel && (
<div className="mt-2 flex items-center justify-end">
<span
title={assigneeLabel}
className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-[10px] font-semibold text-emerald-700"
>
{initials(assigneeLabel)}
</span>
</div> </div>
)} )}
</button>
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon-xs"
disabled={!hasPrev}
onClick={(e) => {
e.stopPropagation();
onMoveStage(deal.id, "prev");
}}
className="text-slate-400 hover:text-white"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={!hasNext}
onClick={(e) => {
e.stopPropagation();
onMoveStage(deal.id, "next");
}}
className="text-slate-400 hover:text-white"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
); );
} }

View File

@ -1,19 +1,35 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { Deal, Contact, PipelineStage } from "@/types"; import type {
Contact,
Conversation,
Deal,
DealStatus,
PipelineStage,
Profile,
} from "@/types";
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, } from "@/components/ui/sheet";
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Check,
X,
Trash2,
MessageSquare,
DollarSign,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
interface DealFormProps { interface DealFormProps {
open: boolean; open: boolean;
@ -34,51 +50,96 @@ export function DealForm({
defaultStageId, defaultStageId,
onSaved, onSaved,
}: DealFormProps) { }: DealFormProps) {
const supabase = createClient();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [currency, setCurrency] = useState("USD"); const [currency, setCurrency] = useState("USD");
const [contactId, setContactId] = useState(""); const [contactId, setContactId] = useState("");
const [stageId, setStageId] = useState(""); const [stageId, setStageId] = useState("");
const [notes, setNotes] = useState(""); const [assignedTo, setAssignedTo] = useState("");
const [expectedCloseDate, setExpectedCloseDate] = useState(""); const [expectedCloseDate, setExpectedCloseDate] = useState("");
const [notes, setNotes] = useState("");
const [contacts, setContacts] = useState<Contact[]>([]); const [contacts, setContacts] = useState<Contact[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [linkedConversation, setLinkedConversation] =
useState<Conversation | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [statusAction, setStatusAction] = useState<DealStatus | null>(null);
const [deleting, setDeleting] = useState(false);
const supabase = createClient(); // Reset when opening
useEffect(() => { useEffect(() => {
if (open) { if (!open) return;
loadContacts();
if (deal) { if (deal) {
setTitle(deal.title); setTitle(deal.title);
setValue(String(deal.value)); setValue(String(deal.value ?? ""));
setCurrency(deal.currency || "USD"); setCurrency(deal.currency || "USD");
setContactId(deal.contact_id); setContactId(deal.contact_id);
setStageId(deal.stage_id); setStageId(deal.stage_id);
setNotes(deal.notes || ""); setAssignedTo(deal.assigned_to ?? "");
setExpectedCloseDate(deal.expected_close_date || ""); setExpectedCloseDate(deal.expected_close_date ?? "");
setNotes(deal.notes ?? "");
} else { } else {
setTitle(""); setTitle("");
setValue(""); setValue("");
setCurrency("USD"); setCurrency("USD");
setContactId(""); setContactId("");
setStageId(defaultStageId || (stages[0]?.id ?? "")); setStageId(defaultStageId || stages[0]?.id || "");
setNotes(""); setAssignedTo("");
setExpectedCloseDate(""); setExpectedCloseDate("");
} setNotes("");
} }
}, [open, deal, defaultStageId, stages]); }, [open, deal, defaultStageId, stages]);
async function loadContacts() { // Load supporting data once the sheet is open
const { data } = await supabase useEffect(() => {
.from("contacts") if (!open) return;
.select("*") let cancelled = false;
.order("name"); (async () => {
if (data) setContacts(data); const [c, p] = await Promise.all([
supabase.from("contacts").select("*").order("name"),
supabase.from("profiles").select("*").order("full_name"),
]);
if (cancelled) return;
setContacts((c.data ?? []) as Contact[]);
setProfiles((p.data ?? []) as Profile[]);
})();
return () => {
cancelled = true;
};
}, [open, supabase]);
// Fetch linked conversation for the selected contact (newest open one).
useEffect(() => {
if (!open || !contactId) {
setLinkedConversation(null);
return;
} }
let cancelled = false;
(async () => {
const { data } = await supabase
.from("conversations")
.select("*")
.eq("contact_id", contactId)
.order("last_message_at", { ascending: false })
.limit(1)
.maybeSingle();
if (cancelled) return;
setLinkedConversation((data as Conversation | null) ?? null);
})();
return () => {
cancelled = true;
};
}, [open, contactId, supabase]);
async function handleSave() { async function handleSave() {
if (!title.trim() || !contactId || !stageId) return; if (!title.trim() || !contactId || !stageId) {
toast.error("Title, contact, and stage are required");
return;
}
setSaving(true); setSaving(true);
const payload = { const payload = {
@ -88,66 +149,154 @@ export function DealForm({
contact_id: contactId, contact_id: contactId,
pipeline_id: pipelineId, pipeline_id: pipelineId,
stage_id: stageId, stage_id: stageId,
assigned_to: assignedTo || null,
notes: notes.trim() || null, notes: notes.trim() || null,
expected_close_date: expectedCloseDate || null, expected_close_date: expectedCloseDate || null,
}; };
if (deal) { if (deal) {
await supabase.from("deals").update(payload).eq("id", deal.id); const { error } = await supabase
.from("deals")
.update(payload)
.eq("id", deal.id);
if (error) {
toast.error("Failed to save deal");
setSaving(false);
return;
}
} else { } else {
const { const {
data: { session }, data: { session },
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (user) { if (!user) {
await supabase toast.error("Not signed in");
setSaving(false);
return;
}
const { error } = await supabase
.from("deals") .from("deals")
.insert({ ...payload, user_id: user.id }); .insert({ ...payload, user_id: user.id, status: "open" });
if (error) {
toast.error("Failed to create deal");
setSaving(false);
return;
} }
} }
setSaving(false); setSaving(false);
toast.success(deal ? "Deal updated" : "Deal created");
onOpenChange(false);
onSaved();
}
async function handleStatusChange(status: DealStatus) {
if (!deal) return;
setStatusAction(status);
const { error } = await supabase
.from("deals")
.update({ status })
.eq("id", deal.id);
setStatusAction(null);
if (error) {
toast.error("Failed to update deal status");
return;
}
toast.success(
status === "won" ? "Marked as won" : status === "lost" ? "Marked as lost" : "Deal reopened",
);
onOpenChange(false);
onSaved();
}
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);
if (error) {
toast.error("Failed to delete deal");
return;
}
toast.success("Deal deleted");
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700"> <SheetContent
<DialogHeader> side="right"
<DialogTitle className="text-white"> className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
>
<div className="flex h-full flex-col">
<SheetHeader className="border-b border-slate-700/50 p-4">
<SheetTitle className="text-white">
{deal ? "Edit Deal" : "New Deal"} {deal ? "Edit Deal" : "New Deal"}
</DialogTitle> </SheetTitle>
</DialogHeader> </SheetHeader>
<div className="grid gap-4 py-2"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Title</Label> <Label className="text-slate-300">Title</Label>
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title" placeholder="Deal title"
className="bg-slate-800 border-slate-700 text-white" className="border-slate-700 bg-slate-800 text-white"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid gap-2">
<Label className="text-slate-300">Contact</Label>
<select
value={contactId}
onChange={(e) => setContactId(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
<option value="">Select a contact</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.phone}
</option>
))}
</select>
{linkedConversation && (
<Link
href="/inbox"
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-emerald-500/10 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/20"
>
<MessageSquare className="h-3 w-3" />
Link to Conversation
</Link>
)}
</div>
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Value</Label> <Label className="text-slate-300">Value</Label>
<div className="relative">
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<Input <Input
type="number" type="number"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="0" placeholder="0"
className="bg-slate-800 border-slate-700 text-white" className="border-slate-700 bg-slate-800 pl-7 text-white"
/> />
</div> </div>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Currency</Label> <Label className="text-slate-300">Currency</Label>
<select <select
value={currency} value={currency}
onChange={(e) => setCurrency(e.target.value)} onChange={(e) => setCurrency(e.target.value)}
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500"
> >
<option value="USD">USD</option> <option value="USD">USD</option>
<option value="EUR">EUR</option> <option value="EUR">EUR</option>
@ -157,19 +306,13 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Contact</Label> <Label className="text-slate-300">Expected Close Date</Label>
<select <Input
value={contactId} type="date"
onChange={(e) => setContactId(e.target.value)} value={expectedCloseDate}
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" onChange={(e) => setExpectedCloseDate(e.target.value)}
> className="border-slate-700 bg-slate-800 text-white"
<option value="">Select a contact</option> />
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.phone}
</option>
))}
</select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -177,7 +320,7 @@ export function DealForm({
<select <select
value={stageId} value={stageId}
onChange={(e) => setStageId(e.target.value)} onChange={(e) => setStageId(e.target.value)}
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500"
> >
{stages.map((s) => ( {stages.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
@ -188,13 +331,19 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Expected Close Date</Label> <Label className="text-slate-300">Assigned To</Label>
<Input <select
type="date" value={assignedTo}
value={expectedCloseDate} onChange={(e) => setAssignedTo(e.target.value)}
onChange={(e) => setExpectedCloseDate(e.target.value)} className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500"
className="bg-slate-800 border-slate-700 text-white" >
/> <option value="">Unassigned</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.full_name || p.email}
</option>
))}
</select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -203,28 +352,94 @@ export function DealForm({
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..." placeholder="Add notes..."
className="bg-slate-800 border-slate-700 text-white min-h-[80px]" className="min-h-[100px] border-slate-700 bg-slate-800 text-white"
/> />
</div> </div>
{deal && (
<div className="space-y-2 rounded-lg border border-slate-700 bg-slate-900/50 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
Status
</p>
<div className="flex gap-2">
<Button
type="button"
onClick={() => handleStatusChange("won")}
disabled={!!statusAction || deal.status === "won"}
className="flex-1 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
>
{statusAction === "won" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-1 h-4 w-4" />
Mark as Won
</>
)}
</Button>
<Button
type="button"
onClick={() => handleStatusChange("lost")}
disabled={!!statusAction || deal.status === "lost"}
className="flex-1 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{statusAction === "lost" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-1 h-4 w-4" />
Mark as Lost
</>
)}
</Button>
</div>
{deal.status && deal.status !== "open" && (
<Button
type="button"
variant="ghost"
onClick={() => handleStatusChange("open")}
disabled={!!statusAction}
className="w-full text-slate-400 hover:text-white"
>
Reopen deal
</Button>
)}
</div>
)}
</div> </div>
<DialogFooter className="bg-slate-900/50 border-slate-700"> <div className="border-t border-slate-700/50 bg-slate-900/80 p-4">
<div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="flex-1 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !title.trim() || !contactId} disabled={saving || !title.trim() || !contactId || !stageId}
className="bg-emerald-600 text-white hover:bg-emerald-700" className="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
> >
{saving ? "Saving..." : deal ? "Update Deal" : "Create Deal"} {saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"}
</Button> </Button>
</DialogFooter> </div>
</DialogContent>
</Dialog> {deal && (
<button
type="button"
onClick={handleDelete}
disabled={deleting}
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"}
</button>
)}
</div>
</div>
</SheetContent>
</Sheet>
); );
} }

View File

@ -0,0 +1,143 @@
"use client";
import { useMemo } from "react";
import type { Deal, PipelineStage } from "@/types";
import { DollarSign, TrendingUp, Target, BarChart3, Trophy, XCircle } from "lucide-react";
interface PipelineAnalyticsProps {
stages: PipelineStage[];
deals: Deal[];
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
/**
* Weighted pipeline value: value × per-stage probability.
* Spec rule: first stage = 10%, last stage before Won = 90%, Won = 100%.
* Stages between are interpolated linearly.
* Won deals count at 100%; Lost deals are excluded.
*/
function computeStageProbability(
stage: PipelineStage,
sortedStages: PipelineStage[],
): number {
const n = sortedStages.length;
if (n <= 1) return 1;
const index = sortedStages.findIndex((s) => s.id === stage.id);
if (index < 0) return 0;
if (index === n - 1) return 1; // final stage (Won)
// First stage → 0.10, second-to-last → 0.90. Evenly spread.
const slots = n - 1; // number of non-final slots
if (slots <= 1) return 0.1;
const t = index / (slots - 1); // 0..1
return 0.1 + t * (0.9 - 0.1);
}
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
const sortedStages = useMemo(
() => [...stages].sort((a, b) => a.position - b.position),
[stages],
);
const stats = useMemo(() => {
const active = deals.filter((d) => d.status !== "lost");
const openDeals = active.filter((d) => d.status !== "won");
const totalCount = active.length;
const totalValue = active.reduce((sum, d) => sum + Number(d.value || 0), 0);
const avgValue = totalCount > 0 ? totalValue / totalCount : 0;
const stageById = new Map(sortedStages.map((s) => [s.id, s]));
const weightedValue = openDeals.reduce((sum, d) => {
const stage = stageById.get(d.stage_id);
if (!stage) return sum;
const prob = computeStageProbability(stage, sortedStages);
return sum + Number(d.value || 0) * prob;
}, 0);
// This-month won/lost counts based on updated_at (falls back to created_at).
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const thisMonth = (d: Deal) => {
const ts = d.updated_at ?? d.created_at;
return ts ? new Date(ts) >= monthStart : false;
};
const wonThisMonth = deals.filter(
(d) => d.status === "won" && thisMonth(d),
).length;
const lostThisMonth = deals.filter(
(d) => d.status === "lost" && thisMonth(d),
).length;
return {
totalCount,
totalValue,
avgValue,
weightedValue,
wonThisMonth,
lostThisMonth,
};
}, [deals, sortedStages]);
return (
<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">
<Metric
icon={<BarChart3 className="h-4 w-4 text-slate-400" />}
label="Total Deals"
value={String(stats.totalCount)}
/>
<Metric
icon={<DollarSign className="h-4 w-4 text-emerald-400" />}
label="Pipeline Value"
value={formatCurrency(stats.totalValue)}
/>
<Metric
icon={<Target className="h-4 w-4 text-blue-400" />}
label="Avg Deal Size"
value={formatCurrency(stats.avgValue)}
/>
<Metric
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
label="Weighted Value"
value={formatCurrency(stats.weightedValue)}
/>
<Metric
icon={<Trophy className="h-4 w-4 text-emerald-400" />}
label="Won This Month"
value={String(stats.wonThisMonth)}
/>
<Metric
icon={<XCircle className="h-4 w-4 text-red-400" />}
label="Lost This Month"
value={String(stats.lostThisMonth)}
/>
</div>
);
}
function Metric({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-lg bg-slate-800/50 p-3">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400">
{icon}
{label}
</div>
<p className="mt-1 text-base font-semibold text-white">{value}</p>
</div>
);
}

View File

@ -1,12 +1,30 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import {
import { PipelineStage, Deal } from "@/types"; DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
useDroppable,
useDraggable,
closestCorners,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import type { Deal, PipelineStage } from "@/types";
import { DealCard } from "./deal-card"; import { DealCard } from "./deal-card";
import { DealForm } from "./deal-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, DollarSign } from "lucide-react"; import { Plus } from "lucide-react";
interface PipelineBoardProps {
stages: PipelineStage[];
deals: Deal[];
onDealMoved: (dealId: string, newStageId: string) => void;
onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void;
}
function formatCurrency(value: number) { function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
@ -17,184 +35,220 @@ function formatCurrency(value: number) {
}).format(value); }).format(value);
} }
interface PipelineBoardProps {
pipelineId: string;
stages: PipelineStage[];
onStagesChange: () => void;
}
export function PipelineBoard({ export function PipelineBoard({
pipelineId,
stages, stages,
onStagesChange, deals,
onDealMoved,
onAddDeal,
onEditDeal,
}: PipelineBoardProps) { }: PipelineBoardProps) {
const [deals, setDeals] = useState<Deal[]>([]); const [activeDealId, setActiveDealId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [dealFormOpen, setDealFormOpen] = useState(false);
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
const [defaultStageId, setDefaultStageId] = useState<string>("");
const supabase = createClient(); const sortedStages = useMemo(
const sortedStages = [...stages].sort((a, b) => a.position - b.position); () => [...stages].sort((a, b) => a.position - b.position),
[stages],
);
useEffect(() => { const dealsByStage = useMemo(() => {
if (pipelineId) { const map = new Map<string, Deal[]>();
loadDeals(); for (const stage of sortedStages) map.set(stage.id, []);
for (const deal of deals) {
const bucket = map.get(deal.stage_id);
if (bucket) bucket.push(deal);
} }
}, [pipelineId]); return map;
}, [sortedStages, deals]);
async function loadDeals() { const sensors = useSensors(
setLoading(true); // 5px activation distance avoids clicks being interpreted as drags.
const { data } = await supabase useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
.from("deals") );
.select("*, contact:contacts(*)")
.eq("pipeline_id", pipelineId) const activeDeal = activeDealId
.order("created_at", { ascending: false }); ? deals.find((d) => d.id === activeDealId) ?? null
if (data) setDeals(data); : null;
setLoading(false);
function handleDragStart(event: DragStartEvent) {
setActiveDealId(String(event.active.id));
} }
function getDealsForStage(stageId: string) { function handleDragEnd(event: DragEndEvent) {
return deals.filter((d) => d.stage_id === stageId); setActiveDealId(null);
} const { active, over } = event;
if (!over) return;
const dealId = String(active.id);
const targetStageId = String(over.id);
function getTotalValue(stageId: string) {
return getDealsForStage(stageId).reduce((sum, d) => sum + d.value, 0);
}
function handleAddDeal(stageId: string) {
setEditingDeal(null);
setDefaultStageId(stageId);
setDealFormOpen(true);
}
function handleEditDeal(deal: Deal) {
setEditingDeal(deal);
setDefaultStageId(deal.stage_id);
setDealFormOpen(true);
}
async function handleMoveStage(dealId: string, direction: "prev" | "next") {
const deal = deals.find((d) => d.id === dealId); const deal = deals.find((d) => d.id === dealId);
if (!deal) return; if (!deal || deal.stage_id === targetStageId) return;
if (!sortedStages.some((s) => s.id === targetStageId)) return;
const currentIndex = sortedStages.findIndex( onDealMoved(dealId, targetStageId);
(s) => s.id === deal.stage_id
);
const targetIndex =
direction === "prev" ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= sortedStages.length) return;
const targetStage = sortedStages[targetIndex];
await supabase
.from("deals")
.update({ stage_id: targetStage.id })
.eq("id", dealId);
setDeals(
deals.map((d) =>
d.id === dealId ? { ...d, stage_id: targetStage.id } : d
)
);
} }
if (loading) { function handleDragCancel() {
return ( setActiveDealId(null);
<div className="flex gap-4 overflow-x-auto pb-4">
{sortedStages.map((stage) => (
<div
key={stage.id}
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
>
<div className="p-3 border-b border-slate-800">
<div className="h-4 w-24 animate-pulse rounded bg-slate-800" />
<div className="mt-2 h-3 w-16 animate-pulse rounded bg-slate-800" />
</div>
<div className="p-3 space-y-3">
{[1, 2].map((i) => (
<div
key={i}
className="h-24 animate-pulse rounded-lg bg-slate-800/50"
/>
))}
</div>
</div>
))}
</div>
);
} }
return ( return (
<> <DndContext
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[calc(100vh-14rem)]"> sensors={sensors}
{sortedStages.map((stage, stageIndex) => { collisionDetection={closestCorners}
const stageDeals = getDealsForStage(stage.id); onDragStart={handleDragStart}
const totalValue = getTotalValue(stage.id); onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="pipeline-scroll flex gap-3 overflow-x-auto pb-4">
{sortedStages.map((stage) => {
const stageDeals = dealsByStage.get(stage.id) ?? [];
const totalValue = stageDeals.reduce(
(s, d) => s + Number(d.value || 0),
0,
);
return ( return (
<div <StageColumn
key={stage.id} key={stage.id}
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50" stage={stage}
> deals={stageDeals}
{/* Column header */} totalValue={totalValue}
<div className="p-3 border-b border-slate-800"> onAddDeal={onAddDeal}
<div className="flex items-center gap-2"> onEditDeal={onEditDeal}
<div
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: stage.color }}
/> />
<h3 className="text-sm font-medium text-white truncate">
{stage.name}
</h3>
<span className="ml-auto shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-slate-800 text-xs text-slate-400">
{stageDeals.length}
</span>
</div>
{totalValue > 0 && (
<div className="mt-1.5 flex items-center gap-1 text-xs text-slate-400">
<DollarSign className="h-3 w-3" />
{formatCurrency(totalValue)}
</div>
)}
</div>
{/* Deal cards */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{stageDeals.map((deal) => (
<DealCard
key={deal.id}
deal={deal}
onEdit={handleEditDeal}
onMoveStage={handleMoveStage}
hasPrev={stageIndex > 0}
hasNext={stageIndex < sortedStages.length - 1}
/>
))}
<Button
variant="ghost"
size="sm"
onClick={() => handleAddDeal(stage.id)}
className="w-full justify-start text-slate-400 hover:text-white border border-dashed border-slate-700 hover:border-slate-600"
>
<Plus className="h-3 w-3 mr-1" />
Add Deal
</Button>
</div>
</div>
); );
})} })}
</div> </div>
<DealForm <DragOverlay
open={dealFormOpen} dropAnimation={{
onOpenChange={setDealFormOpen} duration: 200,
deal={editingDeal} easing: "cubic-bezier(0.2, 0, 0, 1)",
pipelineId={pipelineId} }}
stages={sortedStages} >
defaultStageId={defaultStageId} {activeDeal ? (
onSaved={loadDeals} <div className="opacity-90">
<DealCard
deal={activeDeal}
stage={
sortedStages.find((s) => s.id === activeDeal.stage_id) ?? null
}
onEdit={() => {}}
isOverlay
/> />
</> </div>
) : null}
</DragOverlay>
<style jsx>{`
.pipeline-scroll {
scroll-behavior: smooth;
}
@media (hover: hover) and (pointer: fine) {
.pipeline-scroll::-webkit-scrollbar {
height: 0;
display: none;
}
.pipeline-scroll {
scrollbar-width: none;
}
}
`}</style>
</DndContext>
);
}
function StageColumn({
stage,
deals,
totalValue,
onAddDeal,
onEditDeal,
}: {
stage: PipelineStage;
deals: Deal[];
totalValue: number;
onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return (
<div
className="flex min-w-[280px] max-w-[300px] shrink-0 flex-col rounded-xl p-4"
style={{ backgroundColor: "#f9fafb" }}
>
{/* 3px colored top border (spec) — sits above the column's padding */}
<div
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
style={{ backgroundColor: stage.color }}
/>
<div className="flex items-center justify-between pt-3">
<h3 className="truncate text-sm font-semibold text-gray-900">
{stage.name}
</h3>
<span className="shrink-0 rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-medium text-gray-700">
{deals.length}
</span>
</div>
<p className="text-xs text-gray-500">{formatCurrency(totalValue)}</p>
<div
ref={setNodeRef}
className={`mt-3 flex flex-1 flex-col gap-2 rounded-lg transition-all ${
isOver
? "bg-emerald-50 outline outline-2 outline-dashed outline-emerald-400 outline-offset-2"
: ""
}`}
>
{deals.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/40 py-10 text-xs text-gray-400">
Drop a deal here
</div>
) : (
deals.map((deal) => (
<DraggableDealCard
key={deal.id}
deal={deal}
stage={stage}
onEdit={onEditDeal}
/>
))
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onAddDeal(stage.id)}
className="mt-3 w-full justify-start border border-dashed border-gray-300 bg-transparent text-gray-500 hover:border-gray-400 hover:bg-white hover:text-gray-700"
>
<Plus className="mr-1 h-3 w-3" />
Add Deal
</Button>
</div>
);
}
function DraggableDealCard({
deal,
stage,
onEdit,
}: {
deal: Deal;
stage: PipelineStage;
onEdit: (deal: Deal) => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: deal.id,
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
style={{ opacity: isDragging ? 0.3 : 1, touchAction: "none" }}
>
<DealCard deal={deal} stage={stage} onEdit={onEdit} />
</div>
); );
} }

View File

@ -1,8 +1,23 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
arrayMove,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { Pipeline, PipelineStage } from "@/types"; import type { Pipeline, PipelineStage } from "@/types";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -17,12 +32,12 @@ import {
Trash2, Trash2,
Plus, Plus,
GripVertical, GripVertical,
ArrowUp,
ArrowDown,
AlertTriangle, AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner";
const STAGE_COLORS = [ const STAGE_COLORS = [
"#3b82f6",
"#6366f1", "#6366f1",
"#8b5cf6", "#8b5cf6",
"#ec4899", "#ec4899",
@ -32,7 +47,6 @@ const STAGE_COLORS = [
"#22c55e", "#22c55e",
"#14b8a6", "#14b8a6",
"#06b6d4", "#06b6d4",
"#3b82f6",
]; ];
interface PipelineSettingsProps { interface PipelineSettingsProps {
@ -40,7 +54,9 @@ interface PipelineSettingsProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
pipeline: Pipeline; pipeline: Pipeline;
stages: PipelineStage[]; stages: PipelineStage[];
onUpdated: () => void; onPipelinesChanged: () => void;
onStagesChanged: () => void;
onCreateNewPipeline: () => void;
} }
export function PipelineSettings({ export function PipelineSettings({
@ -48,8 +64,12 @@ export function PipelineSettings({
onOpenChange, onOpenChange,
pipeline, pipeline,
stages, stages,
onUpdated, onPipelinesChanged,
onStagesChanged,
onCreateNewPipeline,
}: PipelineSettingsProps) { }: PipelineSettingsProps) {
const supabase = createClient();
const [name, setName] = useState(pipeline.name); const [name, setName] = useState(pipeline.name);
const [localStages, setLocalStages] = useState<PipelineStage[]>(stages); const [localStages, setLocalStages] = useState<PipelineStage[]>(stages);
const [newStageName, setNewStageName] = useState(""); const [newStageName, setNewStageName] = useState("");
@ -58,112 +78,132 @@ export function PipelineSettings({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const supabase = createClient();
useEffect(() => { useEffect(() => {
if (open) { if (!open) return;
setName(pipeline.name); setName(pipeline.name);
setLocalStages([...stages].sort((a, b) => a.position - b.position)); setLocalStages([...stages].sort((a, b) => a.position - b.position));
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
}
}, [open, pipeline, stages]); }, [open, pipeline, stages]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
function handleReorder(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = localStages.findIndex((s) => s.id === active.id);
const newIndex = localStages.findIndex((s) => s.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
setLocalStages(arrayMove(localStages, oldIndex, newIndex));
}
async function handleSave() { async function handleSave() {
setSaving(true); setSaving(true);
const { error: renameErr } = await supabase
await supabase
.from("pipelines") .from("pipelines")
.update({ name: name.trim() }) .update({ name: name.trim() })
.eq("id", pipeline.id); .eq("id", pipeline.id);
if (renameErr) {
toast.error("Failed to rename pipeline");
setSaving(false);
return;
}
// Persist each stage's name/color/position
for (let i = 0; i < localStages.length; i++) { for (let i = 0; i < localStages.length; i++) {
const stage = localStages[i]; const s = localStages[i];
await supabase await supabase
.from("pipeline_stages") .from("pipeline_stages")
.update({ name: stage.name, color: stage.color, position: i }) .update({ name: s.name, color: s.color, position: i })
.eq("id", stage.id); .eq("id", s.id);
} }
setSaving(false); setSaving(false);
onOpenChange(false); onOpenChange(false);
onUpdated(); onPipelinesChanged();
onStagesChanged();
toast.success("Pipeline saved");
} }
async function handleAddStage() { async function handleAddStage() {
if (!newStageName.trim()) return; const trimmed = newStageName.trim();
const { if (!trimmed) return;
data: { session }, const { data, error } = await supabase
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) return;
const { data } = await supabase
.from("pipeline_stages") .from("pipeline_stages")
.insert({ .insert({
pipeline_id: pipeline.id, pipeline_id: pipeline.id,
name: newStageName.trim(), name: trimmed,
color: newStageColor, color: newStageColor,
position: localStages.length, position: localStages.length,
}) })
.select() .select()
.single(); .single();
if (error || !data) {
if (data) { toast.error("Failed to add stage");
setLocalStages([...localStages, data]); return;
setNewStageName("");
setNewStageColor(
STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]
);
} }
setLocalStages([...localStages, data as PipelineStage]);
setNewStageName("");
setNewStageColor(STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]);
} }
async function handleRemoveStage(stageId: string) { async function handleRemoveStage(stageId: string) {
await supabase.from("pipeline_stages").delete().eq("id", stageId); // Refuse to delete if deals still reference the stage (FK would fail).
setLocalStages(localStages.filter((s) => s.id !== stageId)); const { count } = await supabase
.from("deals")
.select("id", { count: "exact", head: true })
.eq("stage_id", stageId);
if (count && count > 0) {
toast.error("Move or delete deals in this stage first");
return;
} }
const { error } = await supabase
function moveStage(index: number, direction: "up" | "down") { .from("pipeline_stages")
const newStages = [...localStages]; .delete()
const targetIndex = direction === "up" ? index - 1 : index + 1; .eq("id", stageId);
if (targetIndex < 0 || targetIndex >= newStages.length) return; if (error) {
[newStages[index], newStages[targetIndex]] = [ toast.error("Failed to delete stage");
newStages[targetIndex], return;
newStages[index], }
]; setLocalStages(localStages.filter((s) => s.id !== stageId));
setLocalStages(newStages);
} }
async function handleDeletePipeline() { async function handleDeletePipeline() {
setDeleting(true); setDeleting(true);
await supabase.from("deals").delete().eq("pipeline_id", pipeline.id); // ON DELETE CASCADE handles deals + stages.
await supabase const { error } = await supabase
.from("pipeline_stages") .from("pipelines")
.delete() .delete()
.eq("pipeline_id", pipeline.id); .eq("id", pipeline.id);
await supabase.from("pipelines").delete().eq("id", pipeline.id);
setDeleting(false); setDeleting(false);
if (error) {
toast.error("Failed to delete pipeline");
return;
}
onOpenChange(false); onOpenChange(false);
onUpdated(); onPipelinesChanged();
toast.success("Pipeline deleted");
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700"> <DialogContent className="sm:max-w-md bg-slate-900 border-slate-700 max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white">Pipeline Settings</DialogTitle> <DialogTitle className="text-white">Manage Pipeline</DialogTitle>
</DialogHeader> </DialogHeader>
{showDeleteConfirm ? ( {showDeleteConfirm ? (
<div className="py-4"> <div className="py-4">
<div className="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4"> <div className="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0" /> <AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
<div> <div>
<p className="text-sm font-medium text-red-400"> <p className="text-sm font-medium text-red-400">
Delete Pipeline Delete Pipeline
</p> </p>
<p className="mt-1 text-xs text-slate-400"> <p className="mt-1 text-xs text-slate-400">
This will permanently delete the pipeline, all stages, and all This will archive all deals in this pipeline. This cannot be
deals within it. This cannot be undone. undone.
</p> </p>
</div> </div>
</div> </div>
@ -171,12 +211,11 @@ export function PipelineSettings({
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
className="border-slate-700 text-slate-300" className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive"
onClick={handleDeletePipeline} onClick={handleDeletePipeline}
disabled={deleting} disabled={deleting}
className="bg-red-600 text-white hover:bg-red-700" className="bg-red-600 text-white hover:bg-red-700"
@ -193,91 +232,67 @@ export function PipelineSettings({
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="bg-slate-800 border-slate-700 text-white" className="border-slate-700 bg-slate-800 text-white"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Stages</Label> <Label className="text-slate-300">Stages</Label>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleReorder}
>
<SortableContext
items={localStages.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2"> <div className="space-y-2">
{localStages.map((stage, index) => ( {localStages.map((stage, index) => (
<div <SortableStageRow
key={stage.id} key={stage.id}
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2" stage={stage}
> onNameChange={(v) => {
<GripVertical className="h-4 w-4 text-slate-500 shrink-0" />
<div
className="h-4 w-4 rounded-full shrink-0"
style={{ backgroundColor: stage.color }}
/>
<Input
value={stage.name}
onChange={(e) => {
const updated = [...localStages]; const updated = [...localStages];
updated[index] = { updated[index] = { ...updated[index], name: v };
...updated[index],
name: e.target.value,
};
setLocalStages(updated); setLocalStages(updated);
}} }}
className="h-7 bg-transparent border-transparent text-white text-sm focus:border-slate-600" onColorChange={(v) => {
const updated = [...localStages];
updated[index] = { ...updated[index], color: v };
setLocalStages(updated);
}}
onRemove={() => handleRemoveStage(stage.id)}
colors={STAGE_COLORS}
/> />
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-xs"
disabled={index === 0}
onClick={() => moveStage(index, "up")}
className="text-slate-400 hover:text-white"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={index === localStages.length - 1}
onClick={() => moveStage(index, "down")}
className="text-slate-400 hover:text-white"
>
<ArrowDown className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleRemoveStage(stage.id)}
className="text-slate-400 hover:text-red-400"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))} ))}
</div> </div>
</SortableContext>
</DndContext>
<div className="flex items-center gap-2 mt-1"> {/* Add new stage */}
<div className="flex gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{STAGE_COLORS.map((color) => ( {STAGE_COLORS.map((color) => (
<button <button
key={color} key={color}
type="button"
onClick={() => setNewStageColor(color)} onClick={() => setNewStageColor(color)}
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110" className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{ style={{
backgroundColor: color, backgroundColor: color,
borderColor: borderColor:
newStageColor === color newStageColor === color ? "white" : "transparent",
? "white"
: "transparent",
}} }}
aria-label={`Pick color ${color}`}
/> />
))} ))}
</div> </div>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
value={newStageName} value={newStageName}
onChange={(e) => setNewStageName(e.target.value)} onChange={(e) => setNewStageName(e.target.value)}
placeholder="New stage name" placeholder="New stage name"
className="bg-slate-800 border-slate-700 text-white text-sm" className="border-slate-700 bg-slate-800 text-sm text-white"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleAddStage(); if (e.key === "Enter") handleAddStage();
}} }}
@ -287,27 +302,36 @@ export function PipelineSettings({
size="sm" size="sm"
onClick={handleAddStage} onClick={handleAddStage}
disabled={!newStageName.trim()} disabled={!newStageName.trim()}
className="border-slate-700 text-slate-300 shrink-0" className="shrink-0 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
> >
<Plus className="h-3 w-3 mr-1" /> <Plus className="mr-1 h-3 w-3" />
Add Add
</Button> </Button>
</div> </div>
</div> </div>
<Button
variant="outline"
onClick={onCreateNewPipeline}
className="w-full border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
>
<Plus className="mr-1 h-3 w-3" />
Create a new pipeline
</Button>
</div> </div>
<DialogFooter className="bg-slate-900/50 border-slate-700"> <DialogFooter className="border-slate-700 bg-slate-900/50">
<Button <Button
variant="destructive" variant="destructive"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="mr-auto" className="mr-auto bg-red-600 hover:bg-red-700"
> >
Delete Pipeline Delete Pipeline
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
> >
Cancel Cancel
</Button> </Button>
@ -325,3 +349,103 @@ export function PipelineSettings({
</Dialog> </Dialog>
); );
} }
function SortableStageRow({
stage,
onNameChange,
onColorChange,
onRemove,
colors,
}: {
stage: PipelineStage;
onNameChange: (v: string) => void;
onColorChange: (v: string) => void;
onRemove: () => void;
colors: string[];
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stage.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab touch-none text-slate-500 hover:text-slate-300 active:cursor-grabbing"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4" />
</button>
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} />
<Input
value={stage.name}
onChange={(e) => onNameChange(e.target.value)}
className="h-7 flex-1 border-transparent bg-transparent text-sm text-white focus:border-slate-600"
/>
<Button
variant="ghost"
size="icon-xs"
onClick={onRemove}
className="text-slate-400 hover:text-red-400"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
}
function ColorSwatch({
value,
onChange,
colors,
}: {
value: string;
onChange: (v: string) => void;
colors: string[];
}) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="h-4 w-4 rounded-full border border-slate-600"
style={{ backgroundColor: value }}
aria-label="Change color"
/>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-6 z-20 flex flex-wrap gap-1 rounded-lg border border-slate-700 bg-slate-900 p-2 shadow-lg w-36">
{colors.map((c) => (
<button
key={c}
type="button"
onClick={() => {
onChange(c);
setOpen(false);
}}
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: c,
borderColor: c === value ? "white" : "transparent",
}}
/>
))}
</div>
</>
)}
</div>
);
}

View File

@ -134,6 +134,8 @@ export interface PipelineStage {
created_at: string; created_at: string;
} }
export type DealStatus = 'open' | 'won' | 'lost';
export interface Deal { export interface Deal {
id: string; id: string;
user_id: string; user_id: string;
@ -141,15 +143,18 @@ export interface Deal {
stage_id: string; stage_id: string;
contact_id: string; contact_id: string;
conversation_id?: string; conversation_id?: string;
assigned_to?: string;
title: string; title: string;
value: number; value: number;
currency?: string; currency?: string;
notes?: string; notes?: string;
expected_close_date?: string; expected_close_date?: string;
status?: string; status?: DealStatus;
created_at: string; created_at: string;
updated_at?: string;
contact?: Contact; contact?: Contact;
stage?: PipelineStage; stage?: PipelineStage;
assignee?: Profile;
} }
export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed'; export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';

View File

@ -0,0 +1,34 @@
-- ============================================================
-- Pipeline enhancements:
-- * deals.assigned_to — optional FK to profiles.id
-- * deals.status — CHECK constraint ('open', 'won', 'lost')
-- (replaces the old default 'active' with spec-compliant values)
--
-- Idempotent: safe to run multiple times.
-- ============================================================
-- Add assigned_to (nullable, FK to profiles)
ALTER TABLE deals
ADD COLUMN IF NOT EXISTS assigned_to UUID REFERENCES profiles(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_deals_assigned_to ON deals(assigned_to);
-- Normalize status values: any existing 'active' row becomes 'open'
UPDATE deals SET status = 'open' WHERE status = 'active' OR status IS NULL;
-- Replace the old default and enforce allowed values
ALTER TABLE deals ALTER COLUMN status SET DEFAULT 'open';
-- Drop prior CHECK if any (none in 001, but be idempotent)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'deals_status_check' AND conrelid = 'deals'::regclass
) THEN
ALTER TABLE deals DROP CONSTRAINT deals_status_check;
END IF;
END $$;
ALTER TABLE deals
ADD CONSTRAINT deals_status_check CHECK (status IN ('open', 'won', 'lost'));