"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { createClient } from "@/lib/supabase/client"; 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, DialogContent, DialogHeader, 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, ChevronDown, Settings } from "lucide-react"; import { toast } from "sonner"; import { useCan } from "@/hooks/use-can"; // Pipeline creation is admin-class (it's a settings-tier write // under the new RLS), but deal creation is operational and only // requires agent+. The two CTAs gate on different capabilities. const PIPELINE_READ_ONLY_TITLE = "Read-only — your role can't create pipelines"; const DEAL_READ_ONLY_TITLE = "Read-only — your role can't create deals"; // 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 canEditSettings = useCan("edit-settings"); const canCreateDeals = useCan("send-messages"); const [pipelines, setPipelines] = 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); // 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(""); // Guard against double-seeding (React StrictMode double-effect in dev). const seedAttempted = useRef(false); const loadPipelines = useCallback(async () => { const { data, error } = await supabase .from("pipelines") .select("*") .order("created_at"); if (error) { console.error("Failed to load pipelines:", error.message); return []; } return data ?? []; }, [supabase]); 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. // Clearing on no-selection is a legitimate sync with URL/prop // state; the load completion uses async setters inside promise // callbacks (not synchronous in the effect body). useEffect(() => { if (!selectedPipelineId) { // eslint-disable-next-line react-hooks/set-state-in-effect setStages([]); // eslint-disable-next-line react-hooks/set-state-in-effect 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() { const name = newPipelineName.trim(); if (!name) return; setCreating(true); const { data: { session }, } = await supabase.auth.getSession(); const user = session?.user; if (!user) { setCreating(false); return; } const { data: pipeline, error } = await supabase .from("pipelines") .insert({ user_id: user.id, name }) .select() .single(); 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); toast.success("Pipeline created"); } const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId); if (loading) { return (
{[1, 2, 3, 4, 5].map((i) => (
))}
); } return (
{/* Header */}
{/* Pipeline selector dropdown */} {selectedPipeline?.name ?? "Select Pipeline"} {pipelines.length === 0 && ( No pipelines yet )} {pipelines.map((p) => ( setSelectedPipelineId(p.id)} className={ p.id === selectedPipelineId ? "text-primary" : "text-slate-300" } > {p.name} ))} {selectedPipeline && ( setSettingsOpen(true)} className="text-slate-300" > Manage Pipelines )}
{/* Board */} {pipelines.length === 0 ? (

No pipelines yet

Create a pipeline to start tracking deals

) : ( <> )} {/* New Pipeline Dialog */} New Pipeline
setNewPipelineName(e.target.value)} 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 (New Lead → Won) will be created automatically.

{/* Pipeline Settings */} {selectedPipeline && ( { setSettingsOpen(false); setNewPipelineOpen(true); }} /> )} {/* Deal Form (Sheet) */}
); }