From f1eafd5d27c04ffb73ac94bb8f4494378ae7e024 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 16 Apr 2026 22:38:45 +0300 Subject: [PATCH] feat(pipelines): Kanban drag-drop, analytics, deal sheet, contact deals tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package-lock.json | 56 ++ package.json | 3 + src/app/(dashboard)/pipelines/page.tsx | 466 ++++++++++------ .../contacts/contact-detail-view.tsx | 86 ++- src/components/pipelines/deal-card.tsx | 164 +++--- src/components/pipelines/deal-form.tsx | 497 +++++++++++++----- .../pipelines/pipeline-analytics.tsx | 143 +++++ src/components/pipelines/pipeline-board.tsx | 376 +++++++------ .../pipelines/pipeline-settings.tsx | 400 +++++++++----- src/types/index.ts | 7 +- .../migrations/002_pipelines_enhancements.sql | 34 ++ 11 files changed, 1549 insertions(+), 683 deletions(-) create mode 100644 src/components/pipelines/pipeline-analytics.tsx create mode 100644 supabase/migrations/002_pipelines_enhancements.sql diff --git a/package-lock.json b/package-lock.json index 93f64b9..5db6ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 910d9cf..e350adc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx index f249825..4f30901 100644 --- a/src/app/(dashboard)/pipelines/page.tsx +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -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([]); - const [stages, setStages] = useState([]); const [selectedPipelineId, setSelectedPipelineId] = useState(""); + const [stages, setStages] = useState([]); + const [deals, setDeals] = useState([]); 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(null); + const [defaultStageId, setDefaultStageId] = useState(""); - 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 => { + 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 (
-
-
-
-
-
+
+
-
- {[1, 2, 3, 4].map((i) => ( -
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
))}
@@ -151,76 +290,77 @@ export default function PipelinesPage() { return (
{/* Header */} -
-
-

Pipelines

-

- Manage your deals and sales pipeline -

-
-
- {selectedPipeline && ( - - )} + + + {selectedPipeline?.name ?? "Select Pipeline"} + + + + + {pipelines.length === 0 && ( + + No pipelines yet + + )} + {pipelines.map((p) => ( + setSelectedPipelineId(p.id)} + className={ + p.id === selectedPipelineId + ? "text-emerald-400" + : "text-slate-300" + } + > + + {p.name} + + ))} + + {selectedPipeline && ( + setSettingsOpen(true)} + className="text-slate-300" + > + + Manage Pipelines + + )} + + +
+ +
+
- {/* Pipeline selector */} - {pipelines.length > 1 && ( -
- - {dropdownOpen && ( - <> -
setDropdownOpen(false)} - /> -
- {pipelines.map((p) => ( - - ))} -
- - )} -
- )} - - {/* Board or empty state */} + {/* Board */} {pipelines.length === 0 ? (
@@ -228,22 +368,27 @@ export default function PipelinesPage() { No pipelines yet

- Create your first pipeline to start tracking deals + Create a pipeline to start tracking deals

) : ( - loadStages(selectedPipelineId)} - /> + <> + + + )} {/* New Pipeline Dialog */} @@ -257,15 +402,14 @@ export default function PipelinesPage() { 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(); }} />

- Default stages (Lead, Qualified, Proposal, Negotiation, Won) will - be created automatically. + Default stages (New Lead → Won) will be created automatically.

@@ -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) */} +
); } diff --git a/src/components/contacts/contact-detail-view.tsx b/src/components/contacts/contact-detail-view.tsx index f63e6b5..8f5b8f2 100644 --- a/src/components/contacts/contact-detail-view.tsx +++ b/src/components/contacts/contact-detail-view.tsx @@ -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([]); + 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 + + Deals + {/* Details Tab */} @@ -594,6 +618,64 @@ export function ContactDetailView({
)} + + {/* Deals Tab */} + + {loadingDeals ? ( +
+ +
+ ) : deals.length === 0 ? ( +

No deals yet

+ ) : ( +
+ {deals.map((deal) => ( +
+
+

+ {deal.title} +

+ {deal.stage && ( + + {deal.stage.name} + + )} +
+
+ + + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: deal.currency || 'USD', + maximumFractionDigits: 0, + }).format(Number(deal.value || 0))} + + {deal.status && deal.status !== 'open' && ( + + {deal.status} + + )} +
+
+ ))} +
+ )} +
)} diff --git a/src/components/pipelines/deal-card.tsx b/src/components/pipelines/deal-card.tsx index 5c94a4b..d1d23d4 100644 --- a/src/components/pipelines/deal-card.tsx +++ b/src/components/pipelines/deal-card.tsx @@ -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 ( -
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" + - -
-
+ ); } diff --git a/src/components/pipelines/deal-form.tsx b/src/components/pipelines/deal-form.tsx index 20df6a4..52fa0a5 100644 --- a/src/components/pipelines/deal-form.tsx +++ b/src/components/pipelines/deal-form.tsx @@ -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([]); + const [profiles, setProfiles] = useState([]); + const [linkedConversation, setLinkedConversation] = + useState(null); + const [saving, setSaving] = useState(false); + const [statusAction, setStatusAction] = useState(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 ( - - - - - {deal ? "Edit Deal" : "New Deal"} - - + + +
+ + + {deal ? "Edit Deal" : "New Deal"} + + -
-
- - setTitle(e.target.value)} - placeholder="Deal title" - className="bg-slate-800 border-slate-700 text-white" - /> -
- -
+
- + 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" />
+
- + + + {linkedConversation && ( + + + Link to Conversation + + )} +
+ +
+
+ +
+ + setValue(e.target.value)} + placeholder="0" + className="border-slate-700 bg-slate-800 pl-7 text-white" + /> +
+
+
+ + +
+
+ +
+ + setExpectedCloseDate(e.target.value)} + className="border-slate-700 bg-slate-800 text-white" + /> +
+ +
+ +
+ +
+ + +
+ +
+ +