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",
"dependencies": {
"@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/supabase-js": "^2.103.3",
"class-variance-authority": "^0.7.1",
@ -522,6 +525,59 @@
"license": "MIT",
"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": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz",

View File

@ -10,6 +10,9 @@
},
"dependencies": {
"@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/supabase-js": "^2.103.3",
"class-variance-authority": "^0.7.1",

View File

@ -1,10 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
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 { 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 {
Dialog,
@ -13,75 +15,221 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
GitBranch,
Plus,
Settings,
ChevronDown,
} from "lucide-react";
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
import { toast } from "sonner";
const DEFAULT_STAGES = [
{ name: "Lead", color: "#6366f1", position: 0 },
{ name: "Qualified", color: "#8b5cf6", position: 1 },
{ name: "Proposal", color: "#f97316", position: 2 },
{ name: "Negotiation", color: "#eab308", position: 3 },
{ name: "Won", color: "#22c55e", position: 4 },
// Spec-defined seed — name and color per the product spec.
const SPEC_DEFAULT_STAGES = [
{ name: "New Lead", color: "#3b82f6", position: 0 }, // blue
{ name: "Qualified", color: "#eab308", position: 1 }, // yellow
{ name: "Proposal Sent", color: "#f97316", position: 2 }, // orange
{ name: "Negotiation", color: "#8b5cf6", position: 3 }, // purple
{ name: "Won", color: "#22c55e", position: 4 }, // green
];
export default function PipelinesPage() {
const supabase = createClient();
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [stages, setStages] = useState<PipelineStage[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
const [stages, setStages] = useState<PipelineStage[]>([]);
const [deals, setDeals] = useState<Deal[]>([]);
const [loading, setLoading] = useState(true);
// Dialog / sheet state
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
const [newPipelineName, setNewPipelineName] = useState("");
const [creating, setCreating] = 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(() => {
loadPipelines();
}, []);
// Guard against double-seeding (React StrictMode double-effect in dev).
const seedAttempted = useRef(false);
useEffect(() => {
if (selectedPipelineId) {
loadStages(selectedPipelineId);
}
}, [selectedPipelineId]);
async function loadPipelines() {
setLoading(true);
const { data } = await supabase
const loadPipelines = useCallback(async () => {
const { data, error } = await supabase
.from("pipelines")
.select("*")
.order("created_at");
if (data && data.length > 0) {
setPipelines(data);
if (!selectedPipelineId || !data.find((p) => p.id === selectedPipelineId)) {
setSelectedPipelineId(data[0].id);
}
} else {
setPipelines([]);
setSelectedPipelineId("");
if (error) {
console.error("Failed to load pipelines:", error.message);
return [];
}
setLoading(false);
}
return data ?? [];
}, [supabase]);
async function loadStages(pipelineId: string) {
const { data } = await supabase
.from("pipeline_stages")
.select("*")
.eq("pipeline_id", pipelineId)
.order("position");
if (data) setStages(data);
}
const loadStages = useCallback(
async (pipelineId: string) => {
const { data } = await supabase
.from("pipeline_stages")
.select("*")
.eq("pipeline_id", pipelineId)
.order("position");
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() {
if (!newPipelineName.trim()) return;
const name = newPipelineName.trim();
if (!name) return;
setCreating(true);
const {
@ -93,35 +241,32 @@ export default function PipelinesPage() {
return;
}
const { data: pipeline } = await supabase
const { data: pipeline, error } = await supabase
.from("pipelines")
.insert({ user_id: user.id, name: newPipelineName.trim() })
.insert({ user_id: user.id, name })
.select()
.single();
if (pipeline) {
const stageInserts = DEFAULT_STAGES.map((s) => ({
pipeline_id: pipeline.id,
name: s.name,
color: s.color,
position: s.position,
}));
await supabase.from("pipeline_stages").insert(stageInserts);
setNewPipelineName("");
setNewPipelineOpen(false);
setSelectedPipelineId(pipeline.id);
await loadPipelines();
if (error || !pipeline) {
toast.error("Failed to create pipeline");
setCreating(false);
return;
}
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);
setNewPipelineName("");
setNewPipelineOpen(false);
setSelectedPipelineId(pipeline.id);
await refreshPipelines();
setCreating(false);
}
function handleRefresh() {
loadPipelines();
if (selectedPipelineId) {
loadStages(selectedPipelineId);
}
toast.success("Pipeline created");
}
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
@ -130,18 +275,12 @@ export default function PipelinesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="h-7 w-32 animate-pulse rounded bg-slate-800" />
<div className="mt-2 h-4 w-48 animate-pulse rounded bg-slate-800" />
</div>
<div className="h-8 w-28 animate-pulse rounded-lg bg-slate-800" />
<div className="h-8 w-48 animate-pulse rounded bg-slate-800" />
<div className="h-9 w-28 animate-pulse rounded-lg bg-slate-800" />
</div>
<div className="flex gap-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50"
/>
<div className="flex gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50" />
))}
</div>
</div>
@ -151,76 +290,77 @@ export default function PipelinesPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Pipelines</h1>
<p className="mt-1 text-sm text-slate-400">
Manage your deals and sales pipeline
</p>
</div>
<div className="flex items-center gap-2">
{selectedPipeline && (
<Button
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
className="text-slate-400 hover:text-white"
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
{/* Pipeline selector dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
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"
>
<Settings className="h-4 w-4" />
</Button>
)}
<GitBranch className="h-4 w-4 text-emerald-500" />
<span className="font-semibold">
{selectedPipeline?.name ?? "Select Pipeline"}
</span>
<ChevronDown className="h-4 w-4 text-slate-400" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-64 border-slate-700 bg-slate-900 text-slate-200"
>
{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}
</DropdownMenuItem>
))}
<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 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="h-4 w-4 mr-1" />
New Pipeline
<Plus className="mr-1 h-4 w-4" />
Add Deal
</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" />
{selectedPipeline?.name || "Select Pipeline"}
<ChevronDown className="h-4 w-4 text-slate-400" />
</button>
{dropdownOpen && (
<>
<div
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" />
{p.name}
</button>
))}
</div>
</>
)}
</div>
)}
{/* Board or empty state */}
{/* Board */}
{pipelines.length === 0 ? (
<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" />
@ -228,22 +368,27 @@ export default function PipelinesPage() {
No pipelines yet
</h3>
<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>
<Button
onClick={() => setNewPipelineOpen(true)}
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
</Button>
</div>
) : (
<PipelineBoard
pipelineId={selectedPipelineId}
stages={stages}
onStagesChange={() => loadStages(selectedPipelineId)}
/>
<>
<PipelineBoard
stages={stages}
deals={deals}
onDealMoved={handleDealMoved}
onAddDeal={handleAddDeal}
onEditDeal={handleEditDeal}
/>
<PipelineAnalytics stages={stages} deals={deals} />
</>
)}
{/* New Pipeline Dialog */}
@ -257,15 +402,14 @@ export default function PipelinesPage() {
<Input
value={newPipelineName}
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"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreatePipeline();
}}
/>
<p className="mt-2 text-xs text-slate-400">
Default stages (Lead, Qualified, Proposal, Negotiation, Won) will
be created automatically.
Default stages (New Lead → Won) will be created automatically.
</p>
</div>
<DialogFooter className="bg-slate-900/50 border-slate-700">
@ -294,9 +438,25 @@ export default function PipelinesPage() {
onOpenChange={setSettingsOpen}
pipeline={selectedPipeline}
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>
);
}

View File

@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
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 {
Sheet,
SheetContent,
@ -30,6 +30,7 @@ import {
Trash2,
Save,
X,
DollarSign,
} from 'lucide-react';
interface ContactDetailViewProps {
@ -75,6 +76,10 @@ export function ContactDetailView({
const [savingCustom, setSavingCustom] = useState(false);
const [loadingCustom, setLoadingCustom] = useState(false);
// Deals tab
const [deals, setDeals] = useState<Deal[]>([]);
const [loadingDeals, setLoadingDeals] = useState(false);
const fetchContact = useCallback(async () => {
if (!contactId) return;
setLoading(true);
@ -146,14 +151,27 @@ export function ContactDetailView({
setLoadingCustom(false);
}, [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(() => {
if (open && contactId) {
fetchContact();
fetchTags();
fetchNotes();
fetchCustomFields();
fetchDeals();
}
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields]);
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields, fetchDeals]);
async function copyPhone() {
if (!contact) return;
@ -389,6 +407,12 @@ export function ContactDetailView({
>
Custom Fields
</TabsTrigger>
<TabsTrigger
value="deals"
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
>
Deals
</TabsTrigger>
</TabsList>
{/* Details Tab */}
@ -594,6 +618,64 @@ export function ContactDetailView({
</div>
)}
</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>
</div>
)}

View File

@ -1,24 +1,22 @@
"use client";
import { Deal } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
Calendar,
User,
DollarSign,
} from "lucide-react";
import type { Deal, PipelineStage } from "@/types";
import { Calendar, Check, X } from "lucide-react";
interface DealCardProps {
deal: Deal;
stage: PipelineStage | null;
onEdit: (deal: Deal) => void;
isOverlay?: boolean;
}
function formatCurrency(value: number, currency?: string) {
const cur = currency || "USD";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: cur,
currency: currency || "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}).format(Number(value || 0));
}
function formatDate(dateStr: string) {
@ -29,95 +27,87 @@ function formatDate(dateStr: string) {
});
}
interface DealCardProps {
deal: Deal;
onEdit: (deal: Deal) => void;
onMoveStage: (dealId: string, direction: "prev" | "next") => void;
hasPrev: boolean;
hasNext: boolean;
function initials(name?: string, fallback?: string) {
const source = (name || fallback || "?").trim();
if (!source) return "?";
return source.charAt(0).toUpperCase();
}
export function DealCard({
deal,
onEdit,
onMoveStage,
hasPrev,
hasNext,
}: DealCardProps) {
const statusColor =
deal.status === "won"
? "text-emerald-400"
: deal.status === "lost"
? "text-red-400"
: "text-slate-400";
export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
const contactLabel = deal.contact?.name || deal.contact?.phone || "No contact";
const assigneeLabel = deal.assignee?.full_name || null;
return (
<div
onClick={() => onEdit(deal)}
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"
<button
type="button"
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">
<h4 className="text-sm font-medium text-white leading-snug truncate">
{/* 4px left accent bar using stage color */}
<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}
</h4>
{deal.status && deal.status !== "open" && (
<Badge variant="outline" className={statusColor}>
{deal.status}
</Badge>
{deal.status === "won" && (
<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">
<Check className="h-3 w-3" />
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 className="mb-2 flex items-center gap-1.5">
<DollarSign className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-sm font-semibold text-emerald-400">
{/* Contact row */}
<div className="mt-2 flex items-center gap-2">
<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)}
</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 && (
<div className="mb-2 flex items-center gap-1.5">
<Calendar className="h-3 w-3 text-slate-500" />
<span className="text-xs text-slate-400">
{deal.expected_close_date && (
<span className="flex items-center gap-1 text-[11px] text-gray-500">
<Calendar className="h-3 w-3" />
{formatDate(deal.expected_close_date)}
</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 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>
</button>
);
}

View File

@ -1,19 +1,35 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { Deal, Contact, PipelineStage } from "@/types";
import type {
Contact,
Conversation,
Deal,
DealStatus,
PipelineStage,
Profile,
} from "@/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Check,
X,
Trash2,
MessageSquare,
DollarSign,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
interface DealFormProps {
open: boolean;
@ -34,51 +50,96 @@ export function DealForm({
defaultStageId,
onSaved,
}: DealFormProps) {
const supabase = createClient();
const [title, setTitle] = useState("");
const [value, setValue] = useState("");
const [currency, setCurrency] = useState("USD");
const [contactId, setContactId] = useState("");
const [stageId, setStageId] = useState("");
const [notes, setNotes] = useState("");
const [assignedTo, setAssignedTo] = useState("");
const [expectedCloseDate, setExpectedCloseDate] = useState("");
const [notes, setNotes] = useState("");
const [contacts, setContacts] = useState<Contact[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [linkedConversation, setLinkedConversation] =
useState<Conversation | null>(null);
const [saving, setSaving] = useState(false);
const [statusAction, setStatusAction] = useState<DealStatus | null>(null);
const [deleting, setDeleting] = useState(false);
const supabase = createClient();
// Reset when opening
useEffect(() => {
if (open) {
loadContacts();
if (deal) {
setTitle(deal.title);
setValue(String(deal.value));
setCurrency(deal.currency || "USD");
setContactId(deal.contact_id);
setStageId(deal.stage_id);
setNotes(deal.notes || "");
setExpectedCloseDate(deal.expected_close_date || "");
} else {
setTitle("");
setValue("");
setCurrency("USD");
setContactId("");
setStageId(defaultStageId || (stages[0]?.id ?? ""));
setNotes("");
setExpectedCloseDate("");
}
if (!open) return;
if (deal) {
setTitle(deal.title);
setValue(String(deal.value ?? ""));
setCurrency(deal.currency || "USD");
setContactId(deal.contact_id);
setStageId(deal.stage_id);
setAssignedTo(deal.assigned_to ?? "");
setExpectedCloseDate(deal.expected_close_date ?? "");
setNotes(deal.notes ?? "");
} else {
setTitle("");
setValue("");
setCurrency("USD");
setContactId("");
setStageId(defaultStageId || stages[0]?.id || "");
setAssignedTo("");
setExpectedCloseDate("");
setNotes("");
}
}, [open, deal, defaultStageId, stages]);
async function loadContacts() {
const { data } = await supabase
.from("contacts")
.select("*")
.order("name");
if (data) setContacts(data);
}
// Load supporting data once the sheet is open
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
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() {
if (!title.trim() || !contactId || !stageId) return;
if (!title.trim() || !contactId || !stageId) {
toast.error("Title, contact, and stage are required");
return;
}
setSaving(true);
const payload = {
@ -88,143 +149,297 @@ export function DealForm({
contact_id: contactId,
pipeline_id: pipelineId,
stage_id: stageId,
assigned_to: assignedTo || null,
notes: notes.trim() || null,
expected_close_date: expectedCloseDate || null,
};
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 {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (user) {
await supabase
.from("deals")
.insert({ ...payload, user_id: user.id });
if (!user) {
toast.error("Not signed in");
setSaving(false);
return;
}
const { error } = await supabase
.from("deals")
.insert({ ...payload, user_id: user.id, status: "open" });
if (error) {
toast.error("Failed to create deal");
setSaving(false);
return;
}
}
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);
onSaved();
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700">
<DialogHeader>
<DialogTitle className="text-white">
{deal ? "Edit Deal" : "New Deal"}
</DialogTitle>
</DialogHeader>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
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"}
</SheetTitle>
</SheetHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label className="text-slate-300">Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title"
className="bg-slate-800 border-slate-700 text-white"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="grid gap-2">
<Label className="text-slate-300">Value</Label>
<Label className="text-slate-300">Title</Label>
<Input
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0"
className="bg-slate-800 border-slate-700 text-white"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title"
className="border-slate-700 bg-slate-800 text-white"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Currency</Label>
<Label className="text-slate-300">Contact</Label>
<select
value={currency}
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"
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="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<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">
<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
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0"
className="border-slate-700 bg-slate-800 pl-7 text-white"
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Currency</Label>
<select
value={currency}
onChange={(e) => setCurrency(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"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Expected Close Date</Label>
<Input
type="date"
value={expectedCloseDate}
onChange={(e) => setExpectedCloseDate(e.target.value)}
className="border-slate-700 bg-slate-800 text-white"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Stage</Label>
<select
value={stageId}
onChange={(e) => setStageId(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"
>
{stages.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Assigned To</Label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(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"
>
<option value="">Unassigned</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.full_name || p.email}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..."
className="min-h-[100px] border-slate-700 bg-slate-800 text-white"
/>
</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 className="grid gap-2">
<Label className="text-slate-300">Contact</Label>
<select
value={contactId}
onChange={(e) => setContactId(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"
>
<option value="">Select a contact</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.phone}
</option>
))}
</select>
</div>
<div className="border-t border-slate-700/50 bg-slate-900/80 p-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || !title.trim() || !contactId || !stageId}
className="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
>
{saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"}
</Button>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Stage</Label>
<select
value={stageId}
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"
>
{stages.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Expected Close Date</Label>
<Input
type="date"
value={expectedCloseDate}
onChange={(e) => setExpectedCloseDate(e.target.value)}
className="bg-slate-800 border-slate-700 text-white"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..."
className="bg-slate-800 border-slate-700 text-white min-h-[80px]"
/>
{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>
<DialogFooter className="bg-slate-900/50 border-slate-700">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || !title.trim() || !contactId}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{saving ? "Saving..." : deal ? "Update Deal" : "Create Deal"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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";
import { useState, useEffect } from "react";
import { createClient } from "@/lib/supabase/client";
import { PipelineStage, Deal } from "@/types";
import { useMemo, useState } from "react";
import {
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 { DealForm } from "./deal-form";
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) {
return new Intl.NumberFormat("en-US", {
@ -17,184 +35,220 @@ function formatCurrency(value: number) {
}).format(value);
}
interface PipelineBoardProps {
pipelineId: string;
stages: PipelineStage[];
onStagesChange: () => void;
}
export function PipelineBoard({
pipelineId,
stages,
onStagesChange,
deals,
onDealMoved,
onAddDeal,
onEditDeal,
}: PipelineBoardProps) {
const [deals, setDeals] = useState<Deal[]>([]);
const [loading, setLoading] = useState(true);
const [dealFormOpen, setDealFormOpen] = useState(false);
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
const [defaultStageId, setDefaultStageId] = useState<string>("");
const [activeDealId, setActiveDealId] = useState<string | null>(null);
const supabase = createClient();
const sortedStages = [...stages].sort((a, b) => a.position - b.position);
const sortedStages = useMemo(
() => [...stages].sort((a, b) => a.position - b.position),
[stages],
);
useEffect(() => {
if (pipelineId) {
loadDeals();
const dealsByStage = useMemo(() => {
const map = new Map<string, Deal[]>();
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() {
setLoading(true);
const { data } = await supabase
.from("deals")
.select("*, contact:contacts(*)")
.eq("pipeline_id", pipelineId)
.order("created_at", { ascending: false });
if (data) setDeals(data);
setLoading(false);
const sensors = useSensors(
// 5px activation distance avoids clicks being interpreted as drags.
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const activeDeal = activeDealId
? deals.find((d) => d.id === activeDealId) ?? null
: null;
function handleDragStart(event: DragStartEvent) {
setActiveDealId(String(event.active.id));
}
function getDealsForStage(stageId: string) {
return deals.filter((d) => d.stage_id === stageId);
}
function handleDragEnd(event: DragEndEvent) {
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);
if (!deal) return;
if (!deal || deal.stage_id === targetStageId) return;
if (!sortedStages.some((s) => s.id === targetStageId)) return;
const currentIndex = sortedStages.findIndex(
(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
)
);
onDealMoved(dealId, targetStageId);
}
if (loading) {
return (
<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>
);
function handleDragCancel() {
setActiveDealId(null);
}
return (
<>
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[calc(100vh-14rem)]">
{sortedStages.map((stage, stageIndex) => {
const stageDeals = getDealsForStage(stage.id);
const totalValue = getTotalValue(stage.id);
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
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 (
<div
<StageColumn
key={stage.id}
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
>
{/* Column header */}
<div className="p-3 border-b border-slate-800">
<div className="flex items-center gap-2">
<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>
stage={stage}
deals={stageDeals}
totalValue={totalValue}
onAddDeal={onAddDeal}
onEditDeal={onEditDeal}
/>
);
})}
</div>
<DealForm
open={dealFormOpen}
onOpenChange={setDealFormOpen}
deal={editingDeal}
pipelineId={pipelineId}
stages={sortedStages}
defaultStageId={defaultStageId}
onSaved={loadDeals}
/>
</>
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.2, 0, 0, 1)",
}}
>
{activeDeal ? (
<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";
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 { Pipeline, PipelineStage } from "@/types";
import type { Pipeline, PipelineStage } from "@/types";
import {
Dialog,
DialogContent,
@ -17,12 +32,12 @@ import {
Trash2,
Plus,
GripVertical,
ArrowUp,
ArrowDown,
AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
const STAGE_COLORS = [
"#3b82f6",
"#6366f1",
"#8b5cf6",
"#ec4899",
@ -32,7 +47,6 @@ const STAGE_COLORS = [
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
];
interface PipelineSettingsProps {
@ -40,7 +54,9 @@ interface PipelineSettingsProps {
onOpenChange: (open: boolean) => void;
pipeline: Pipeline;
stages: PipelineStage[];
onUpdated: () => void;
onPipelinesChanged: () => void;
onStagesChanged: () => void;
onCreateNewPipeline: () => void;
}
export function PipelineSettings({
@ -48,8 +64,12 @@ export function PipelineSettings({
onOpenChange,
pipeline,
stages,
onUpdated,
onPipelinesChanged,
onStagesChanged,
onCreateNewPipeline,
}: PipelineSettingsProps) {
const supabase = createClient();
const [name, setName] = useState(pipeline.name);
const [localStages, setLocalStages] = useState<PipelineStage[]>(stages);
const [newStageName, setNewStageName] = useState("");
@ -58,112 +78,132 @@ export function PipelineSettings({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const supabase = createClient();
useEffect(() => {
if (open) {
setName(pipeline.name);
setLocalStages([...stages].sort((a, b) => a.position - b.position));
setShowDeleteConfirm(false);
}
if (!open) return;
setName(pipeline.name);
setLocalStages([...stages].sort((a, b) => a.position - b.position));
setShowDeleteConfirm(false);
}, [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() {
setSaving(true);
await supabase
const { error: renameErr } = await supabase
.from("pipelines")
.update({ name: name.trim() })
.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++) {
const stage = localStages[i];
const s = localStages[i];
await supabase
.from("pipeline_stages")
.update({ name: stage.name, color: stage.color, position: i })
.eq("id", stage.id);
.update({ name: s.name, color: s.color, position: i })
.eq("id", s.id);
}
setSaving(false);
onOpenChange(false);
onUpdated();
onPipelinesChanged();
onStagesChanged();
toast.success("Pipeline saved");
}
async function handleAddStage() {
if (!newStageName.trim()) return;
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) return;
const { data } = await supabase
const trimmed = newStageName.trim();
if (!trimmed) return;
const { data, error } = await supabase
.from("pipeline_stages")
.insert({
pipeline_id: pipeline.id,
name: newStageName.trim(),
name: trimmed,
color: newStageColor,
position: localStages.length,
})
.select()
.single();
if (data) {
setLocalStages([...localStages, data]);
setNewStageName("");
setNewStageColor(
STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]
);
if (error || !data) {
toast.error("Failed to add stage");
return;
}
setLocalStages([...localStages, data as PipelineStage]);
setNewStageName("");
setNewStageColor(STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]);
}
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).
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
.from("pipeline_stages")
.delete()
.eq("id", stageId);
if (error) {
toast.error("Failed to delete stage");
return;
}
setLocalStages(localStages.filter((s) => s.id !== stageId));
}
function moveStage(index: number, direction: "up" | "down") {
const newStages = [...localStages];
const targetIndex = direction === "up" ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newStages.length) return;
[newStages[index], newStages[targetIndex]] = [
newStages[targetIndex],
newStages[index],
];
setLocalStages(newStages);
}
async function handleDeletePipeline() {
setDeleting(true);
await supabase.from("deals").delete().eq("pipeline_id", pipeline.id);
await supabase
.from("pipeline_stages")
// ON DELETE CASCADE handles deals + stages.
const { error } = await supabase
.from("pipelines")
.delete()
.eq("pipeline_id", pipeline.id);
await supabase.from("pipelines").delete().eq("id", pipeline.id);
.eq("id", pipeline.id);
setDeleting(false);
if (error) {
toast.error("Failed to delete pipeline");
return;
}
onOpenChange(false);
onUpdated();
onPipelinesChanged();
toast.success("Pipeline deleted");
}
return (
<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>
<DialogTitle className="text-white">Pipeline Settings</DialogTitle>
<DialogTitle className="text-white">Manage Pipeline</DialogTitle>
</DialogHeader>
{showDeleteConfirm ? (
<div className="py-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>
<p className="text-sm font-medium text-red-400">
Delete Pipeline
</p>
<p className="mt-1 text-xs text-slate-400">
This will permanently delete the pipeline, all stages, and all
deals within it. This cannot be undone.
This will archive all deals in this pipeline. This cannot be
undone.
</p>
</div>
</div>
@ -171,12 +211,11 @@ export function PipelineSettings({
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
className="border-slate-700 text-slate-300"
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeletePipeline}
disabled={deleting}
className="bg-red-600 text-white hover:bg-red-700"
@ -193,91 +232,67 @@ export function PipelineSettings({
<Input
value={name}
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 className="grid gap-2">
<Label className="text-slate-300">Stages</Label>
<div className="space-y-2">
{localStages.map((stage, index) => (
<div
key={stage.id}
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
>
<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];
updated[index] = {
...updated[index],
name: e.target.value,
};
setLocalStages(updated);
}}
className="h-7 bg-transparent border-transparent text-white text-sm focus:border-slate-600"
/>
<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>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleReorder}
>
<SortableContext
items={localStages.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{localStages.map((stage, index) => (
<SortableStageRow
key={stage.id}
stage={stage}
onNameChange={(v) => {
const updated = [...localStages];
updated[index] = { ...updated[index], name: v };
setLocalStages(updated);
}}
onColorChange={(v) => {
const updated = [...localStages];
updated[index] = { ...updated[index], color: v };
setLocalStages(updated);
}}
onRemove={() => handleRemoveStage(stage.id)}
colors={STAGE_COLORS}
/>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
<div className="flex items-center gap-2 mt-1">
<div className="flex gap-1">
{STAGE_COLORS.map((color) => (
<button
key={color}
onClick={() => setNewStageColor(color)}
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: color,
borderColor:
newStageColor === color
? "white"
: "transparent",
}}
/>
))}
</div>
{/* Add new stage */}
<div className="mt-1 flex flex-wrap gap-1">
{STAGE_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewStageColor(color)}
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: color,
borderColor:
newStageColor === color ? "white" : "transparent",
}}
aria-label={`Pick color ${color}`}
/>
))}
</div>
<div className="flex items-center gap-2">
<Input
value={newStageName}
onChange={(e) => setNewStageName(e.target.value)}
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) => {
if (e.key === "Enter") handleAddStage();
}}
@ -287,27 +302,36 @@ export function PipelineSettings({
size="sm"
onClick={handleAddStage}
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
</Button>
</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>
<DialogFooter className="bg-slate-900/50 border-slate-700">
<DialogFooter className="border-slate-700 bg-slate-900/50">
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
className="mr-auto"
className="mr-auto bg-red-600 hover:bg-red-700"
>
Delete Pipeline
</Button>
<Button
variant="outline"
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
</Button>
@ -325,3 +349,103 @@ export function PipelineSettings({
</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;
}
export type DealStatus = 'open' | 'won' | 'lost';
export interface Deal {
id: string;
user_id: string;
@ -141,15 +143,18 @@ export interface Deal {
stage_id: string;
contact_id: string;
conversation_id?: string;
assigned_to?: string;
title: string;
value: number;
currency?: string;
notes?: string;
expected_close_date?: string;
status?: string;
status?: DealStatus;
created_at: string;
updated_at?: string;
contact?: Contact;
stage?: PipelineStage;
assignee?: Profile;
}
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'));