mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
3615e8cacf
commit
f1eafd5d27
56
package-lock.json
generated
56
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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("");
|
||||
}
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
console.error("Failed to load pipelines:", error.message);
|
||||
return [];
|
||||
}
|
||||
return data ?? [];
|
||||
}, [supabase]);
|
||||
|
||||
async function loadStages(pipelineId: string) {
|
||||
const loadStages = useCallback(
|
||||
async (pipelineId: string) => {
|
||||
const { data } = await supabase
|
||||
.from("pipeline_stages")
|
||||
.select("*")
|
||||
.eq("pipeline_id", pipelineId)
|
||||
.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() {
|
||||
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) => ({
|
||||
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(stageInserts);
|
||||
await supabase.from("pipeline_stages").insert(stagesPayload);
|
||||
|
||||
setNewPipelineName("");
|
||||
setNewPipelineOpen(false);
|
||||
setSelectedPipelineId(pipeline.id);
|
||||
await loadPipelines();
|
||||
}
|
||||
|
||||
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 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="h-8 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"
|
||||
>
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
</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"
|
||||
}`}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-64 border-slate-700 bg-slate-900 text-slate-200"
|
||||
>
|
||||
<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}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<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)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (!open) return;
|
||||
if (deal) {
|
||||
setTitle(deal.title);
|
||||
setValue(String(deal.value));
|
||||
setValue(String(deal.value ?? ""));
|
||||
setCurrency(deal.currency || "USD");
|
||||
setContactId(deal.contact_id);
|
||||
setStageId(deal.stage_id);
|
||||
setNotes(deal.notes || "");
|
||||
setExpectedCloseDate(deal.expected_close_date || "");
|
||||
setAssignedTo(deal.assigned_to ?? "");
|
||||
setExpectedCloseDate(deal.expected_close_date ?? "");
|
||||
setNotes(deal.notes ?? "");
|
||||
} else {
|
||||
setTitle("");
|
||||
setValue("");
|
||||
setCurrency("USD");
|
||||
setContactId("");
|
||||
setStageId(defaultStageId || (stages[0]?.id ?? ""));
|
||||
setNotes("");
|
||||
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,66 +149,154 @@ 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
|
||||
if (!user) {
|
||||
toast.error("Not signed in");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const { error } = await supabase
|
||||
.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);
|
||||
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">
|
||||
<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"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
</SheetTitle>
|
||||
</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">
|
||||
<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"
|
||||
className="border-slate-700 bg-slate-800 text-white"
|
||||
/>
|
||||
</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">
|
||||
<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="bg-slate-800 border-slate-700 text-white"
|
||||
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-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="EUR">EUR</option>
|
||||
@ -157,19 +306,13 @@ export function DealForm({
|
||||
</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>
|
||||
<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">
|
||||
@ -177,7 +320,7 @@ export function DealForm({
|
||||
<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"
|
||||
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}>
|
||||
@ -188,13 +331,19 @@ export function DealForm({
|
||||
</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"
|
||||
/>
|
||||
<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">
|
||||
@ -203,28 +352,94 @@ export function DealForm({
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
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>
|
||||
|
||||
{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>
|
||||
|
||||
<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
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !title.trim() || !contactId}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={saving || !title.trim() || !contactId || !stageId}
|
||||
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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
143
src/components/pipelines/pipeline-analytics.tsx
Normal file
143
src/components/pipelines/pipeline-analytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }}
|
||||
stage={stage}
|
||||
deals={stageDeals}
|
||||
totalValue={totalValue}
|
||||
onAddDeal={onAddDeal}
|
||||
onEditDeal={onEditDeal}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
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);
|
||||
setLocalStages(localStages.filter((s) => s.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;
|
||||
}
|
||||
|
||||
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);
|
||||
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));
|
||||
}
|
||||
|
||||
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>
|
||||
<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) => (
|
||||
<div
|
||||
<SortableStageRow
|
||||
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) => {
|
||||
stage={stage}
|
||||
onNameChange={(v) => {
|
||||
const updated = [...localStages];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
name: e.target.value,
|
||||
};
|
||||
updated[index] = { ...updated[index], name: v };
|
||||
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>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex gap-1">
|
||||
{/* 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",
|
||||
newStageColor === color ? "white" : "transparent",
|
||||
}}
|
||||
aria-label={`Pick color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
34
supabase/migrations/002_pipelines_enhancements.sql
Normal file
34
supabase/migrations/002_pipelines_enhancements.sql
Normal 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'));
|
||||
Loading…
Reference in New Issue
Block a user