mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #18 from ArnasDon/feat/pipelines-kanban-dnd
feat(pipelines): Kanban drag-drop, analytics, deal sheet
This commit is contained in:
commit
57f05fb143
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@supabase/ssr": "^0.10.2",
|
"@supabase/ssr": "^0.10.2",
|
||||||
"@supabase/supabase-js": "^2.103.3",
|
"@supabase/supabase-js": "^2.103.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -522,6 +525,59 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz",
|
||||||
|
|||||||
@ -10,6 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@supabase/ssr": "^0.10.2",
|
"@supabase/ssr": "^0.10.2",
|
||||||
"@supabase/supabase-js": "^2.103.3",
|
"@supabase/supabase-js": "^2.103.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { Pipeline, PipelineStage } from "@/types";
|
import type { Pipeline, PipelineStage, Deal } from "@/types";
|
||||||
import { PipelineBoard } from "@/components/pipelines/pipeline-board";
|
import { PipelineBoard } from "@/components/pipelines/pipeline-board";
|
||||||
import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
|
import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
|
||||||
|
import { DealForm } from "@/components/pipelines/deal-form";
|
||||||
|
import { PipelineAnalytics } from "@/components/pipelines/pipeline-analytics";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -13,75 +15,221 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
|
||||||
GitBranch,
|
import { toast } from "sonner";
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const DEFAULT_STAGES = [
|
// Spec-defined seed — name and color per the product spec.
|
||||||
{ name: "Lead", color: "#6366f1", position: 0 },
|
const SPEC_DEFAULT_STAGES = [
|
||||||
{ name: "Qualified", color: "#8b5cf6", position: 1 },
|
{ name: "New Lead", color: "#3b82f6", position: 0 }, // blue
|
||||||
{ name: "Proposal", color: "#f97316", position: 2 },
|
{ name: "Qualified", color: "#eab308", position: 1 }, // yellow
|
||||||
{ name: "Negotiation", color: "#eab308", position: 3 },
|
{ name: "Proposal Sent", color: "#f97316", position: 2 }, // orange
|
||||||
{ name: "Won", color: "#22c55e", position: 4 },
|
{ name: "Negotiation", color: "#8b5cf6", position: 3 }, // purple
|
||||||
|
{ name: "Won", color: "#22c55e", position: 4 }, // green
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function PipelinesPage() {
|
export default function PipelinesPage() {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
||||||
const [stages, setStages] = useState<PipelineStage[]>([]);
|
|
||||||
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
|
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
|
||||||
|
const [stages, setStages] = useState<PipelineStage[]>([]);
|
||||||
|
const [deals, setDeals] = useState<Deal[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Dialog / sheet state
|
||||||
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
|
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
|
||||||
const [newPipelineName, setNewPipelineName] = useState("");
|
const [newPipelineName, setNewPipelineName] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
|
|
||||||
const supabase = createClient();
|
// Deal form state is lifted here so both the top-bar "Add Deal" and
|
||||||
|
// the per-column "+" trigger the same Sheet.
|
||||||
|
const [dealFormOpen, setDealFormOpen] = useState(false);
|
||||||
|
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
|
||||||
|
const [defaultStageId, setDefaultStageId] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
// Guard against double-seeding (React StrictMode double-effect in dev).
|
||||||
loadPipelines();
|
const seedAttempted = useRef(false);
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const loadPipelines = useCallback(async () => {
|
||||||
if (selectedPipelineId) {
|
const { data, error } = await supabase
|
||||||
loadStages(selectedPipelineId);
|
|
||||||
}
|
|
||||||
}, [selectedPipelineId]);
|
|
||||||
|
|
||||||
async function loadPipelines() {
|
|
||||||
setLoading(true);
|
|
||||||
const { data } = await supabase
|
|
||||||
.from("pipelines")
|
.from("pipelines")
|
||||||
.select("*")
|
.select("*")
|
||||||
.order("created_at");
|
.order("created_at");
|
||||||
if (data && data.length > 0) {
|
if (error) {
|
||||||
setPipelines(data);
|
console.error("Failed to load pipelines:", error.message);
|
||||||
if (!selectedPipelineId || !data.find((p) => p.id === selectedPipelineId)) {
|
return [];
|
||||||
setSelectedPipelineId(data[0].id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setPipelines([]);
|
|
||||||
setSelectedPipelineId("");
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
return data ?? [];
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
async function loadStages(pipelineId: string) {
|
const loadStages = useCallback(
|
||||||
|
async (pipelineId: string) => {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("pipeline_stages")
|
.from("pipeline_stages")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("pipeline_id", pipelineId)
|
.eq("pipeline_id", pipelineId)
|
||||||
.order("position");
|
.order("position");
|
||||||
if (data) setStages(data);
|
return data ?? [];
|
||||||
|
},
|
||||||
|
[supabase],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDeals = useCallback(
|
||||||
|
async (pipelineId: string) => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("deals")
|
||||||
|
.select("*, contact:contacts(*), assignee:profiles!deals_assigned_to_fkey(*)")
|
||||||
|
.eq("pipeline_id", pipelineId)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
return (data ?? []) as Deal[];
|
||||||
|
},
|
||||||
|
[supabase],
|
||||||
|
);
|
||||||
|
|
||||||
|
const seedDefaultPipeline = useCallback(async (): Promise<Pipeline | null> => {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
const user = session?.user;
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const { data: pipeline, error } = await supabase
|
||||||
|
.from("pipelines")
|
||||||
|
.insert({ user_id: user.id, name: "Sales Pipeline" })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !pipeline) {
|
||||||
|
console.error("Failed to seed pipeline:", error?.message);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
|
||||||
|
pipeline_id: pipeline.id,
|
||||||
|
name: s.name,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
}));
|
||||||
|
await supabase.from("pipeline_stages").insert(stagesPayload);
|
||||||
|
|
||||||
|
return pipeline as Pipeline;
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
// Initial load + seed-if-empty
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
let list = await loadPipelines();
|
||||||
|
|
||||||
|
if (list.length === 0 && !seedAttempted.current) {
|
||||||
|
seedAttempted.current = true;
|
||||||
|
const seeded = await seedDefaultPipeline();
|
||||||
|
if (seeded) list = await loadPipelines();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setPipelines(list);
|
||||||
|
if (list.length > 0) {
|
||||||
|
setSelectedPipelineId((prev) =>
|
||||||
|
prev && list.some((p) => p.id === prev) ? prev : list[0].id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedPipelineId("");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [loadPipelines, seedDefaultPipeline]);
|
||||||
|
|
||||||
|
// Load stages + deals whenever selected pipeline changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPipelineId) {
|
||||||
|
setStages([]);
|
||||||
|
setDeals([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [s, d] = await Promise.all([
|
||||||
|
loadStages(selectedPipelineId),
|
||||||
|
loadDeals(selectedPipelineId),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setStages(s);
|
||||||
|
setDeals(d);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedPipelineId, loadStages, loadDeals]);
|
||||||
|
|
||||||
|
const refreshPipelines = useCallback(async () => {
|
||||||
|
const list = await loadPipelines();
|
||||||
|
setPipelines(list);
|
||||||
|
if (list.length === 0) setSelectedPipelineId("");
|
||||||
|
else if (!list.some((p) => p.id === selectedPipelineId))
|
||||||
|
setSelectedPipelineId(list[0].id);
|
||||||
|
}, [loadPipelines, selectedPipelineId]);
|
||||||
|
|
||||||
|
const refreshStages = useCallback(async () => {
|
||||||
|
if (!selectedPipelineId) return;
|
||||||
|
setStages(await loadStages(selectedPipelineId));
|
||||||
|
}, [loadStages, selectedPipelineId]);
|
||||||
|
|
||||||
|
const refreshDeals = useCallback(async () => {
|
||||||
|
if (!selectedPipelineId) return;
|
||||||
|
setDeals(await loadDeals(selectedPipelineId));
|
||||||
|
}, [loadDeals, selectedPipelineId]);
|
||||||
|
|
||||||
|
const handleDealMoved = useCallback(
|
||||||
|
async (dealId: string, newStageId: string) => {
|
||||||
|
// Optimistic update — board already animated; just persist.
|
||||||
|
setDeals((prev) =>
|
||||||
|
prev.map((d) => (d.id === dealId ? { ...d, stage_id: newStageId } : d)),
|
||||||
|
);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("deals")
|
||||||
|
.update({ stage_id: newStageId })
|
||||||
|
.eq("id", dealId);
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to move deal");
|
||||||
|
refreshDeals();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[supabase, refreshDeals],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddDeal = useCallback(
|
||||||
|
(stageId?: string) => {
|
||||||
|
setEditingDeal(null);
|
||||||
|
setDefaultStageId(stageId ?? stages[0]?.id ?? "");
|
||||||
|
setDealFormOpen(true);
|
||||||
|
},
|
||||||
|
[stages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditDeal = useCallback((deal: Deal) => {
|
||||||
|
setEditingDeal(deal);
|
||||||
|
setDefaultStageId(deal.stage_id);
|
||||||
|
setDealFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleCreatePipeline() {
|
async function handleCreatePipeline() {
|
||||||
if (!newPipelineName.trim()) return;
|
const name = newPipelineName.trim();
|
||||||
|
if (!name) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -93,35 +241,32 @@ export default function PipelinesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: pipeline } = await supabase
|
const { data: pipeline, error } = await supabase
|
||||||
.from("pipelines")
|
.from("pipelines")
|
||||||
.insert({ user_id: user.id, name: newPipelineName.trim() })
|
.insert({ user_id: user.id, name })
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (pipeline) {
|
if (error || !pipeline) {
|
||||||
const stageInserts = DEFAULT_STAGES.map((s) => ({
|
toast.error("Failed to create pipeline");
|
||||||
|
setCreating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
|
||||||
pipeline_id: pipeline.id,
|
pipeline_id: pipeline.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
color: s.color,
|
color: s.color,
|
||||||
position: s.position,
|
position: s.position,
|
||||||
}));
|
}));
|
||||||
await supabase.from("pipeline_stages").insert(stageInserts);
|
await supabase.from("pipeline_stages").insert(stagesPayload);
|
||||||
|
|
||||||
setNewPipelineName("");
|
setNewPipelineName("");
|
||||||
setNewPipelineOpen(false);
|
setNewPipelineOpen(false);
|
||||||
setSelectedPipelineId(pipeline.id);
|
setSelectedPipelineId(pipeline.id);
|
||||||
await loadPipelines();
|
await refreshPipelines();
|
||||||
}
|
|
||||||
|
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
toast.success("Pipeline created");
|
||||||
|
|
||||||
function handleRefresh() {
|
|
||||||
loadPipelines();
|
|
||||||
if (selectedPipelineId) {
|
|
||||||
loadStages(selectedPipelineId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
|
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
|
||||||
@ -130,18 +275,12 @@ export default function PipelinesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="h-8 w-48 animate-pulse rounded bg-slate-800" />
|
||||||
<div className="h-7 w-32 animate-pulse rounded bg-slate-800" />
|
<div className="h-9 w-28 animate-pulse rounded-lg bg-slate-800" />
|
||||||
<div className="mt-2 h-4 w-48 animate-pulse rounded bg-slate-800" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-8 w-28 animate-pulse rounded-lg bg-slate-800" />
|
<div className="flex gap-3">
|
||||||
</div>
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<div className="flex gap-4">
|
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50" />
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -151,76 +290,77 @@ export default function PipelinesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-white">Pipelines</h1>
|
{/* Pipeline selector dropdown */}
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
<DropdownMenu>
|
||||||
Manage your deals and sales pipeline
|
<DropdownMenuTrigger
|
||||||
</p>
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors data-[popup-open]:bg-slate-800"
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{selectedPipeline && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setSettingsOpen(true)}
|
|
||||||
className="text-slate-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => setNewPipelineOpen(true)}
|
|
||||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
New Pipeline
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pipeline selector */}
|
|
||||||
{pipelines.length > 1 && (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
>
|
||||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||||
{selectedPipeline?.name || "Select Pipeline"}
|
<span className="font-semibold">
|
||||||
|
{selectedPipeline?.name ?? "Select Pipeline"}
|
||||||
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||||
</button>
|
</DropdownMenuTrigger>
|
||||||
{dropdownOpen && (
|
<DropdownMenuContent
|
||||||
<>
|
align="start"
|
||||||
<div
|
className="w-64 border-slate-700 bg-slate-900 text-slate-200"
|
||||||
className="fixed inset-0 z-10"
|
|
||||||
onClick={() => setDropdownOpen(false)}
|
|
||||||
/>
|
|
||||||
<div className="absolute top-full left-0 z-20 mt-1 w-56 rounded-lg border border-slate-700 bg-slate-900 py-1 shadow-lg">
|
|
||||||
{pipelines.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedPipelineId(p.id);
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
|
||||||
p.id === selectedPipelineId
|
|
||||||
? "bg-emerald-500/10 text-emerald-400"
|
|
||||||
: "text-slate-300 hover:bg-slate-800"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
{pipelines.length === 0 && (
|
||||||
|
<DropdownMenuItem disabled className="text-slate-500">
|
||||||
|
No pipelines yet
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{pipelines.map((p) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setSelectedPipelineId(p.id)}
|
||||||
|
className={
|
||||||
|
p.id === selectedPipelineId
|
||||||
|
? "text-emerald-400"
|
||||||
|
: "text-slate-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GitBranch className="mr-2 h-3.5 w-3.5" />
|
||||||
{p.name}
|
{p.name}
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
<DropdownMenuSeparator className="bg-slate-700" />
|
||||||
</>
|
{selectedPipeline && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
className="text-slate-300"
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Manage Pipelines
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Board or empty state */}
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setNewPipelineOpen(true)}
|
||||||
|
className="border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Add Pipeline
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAddDeal()}
|
||||||
|
disabled={!selectedPipelineId || stages.length === 0}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Add Deal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Board */}
|
||||||
{pipelines.length === 0 ? (
|
{pipelines.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20">
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20">
|
||||||
<GitBranch className="h-12 w-12 text-slate-600" />
|
<GitBranch className="h-12 w-12 text-slate-600" />
|
||||||
@ -228,22 +368,27 @@ export default function PipelinesPage() {
|
|||||||
No pipelines yet
|
No pipelines yet
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-slate-400">
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
Create your first pipeline to start tracking deals
|
Create a pipeline to start tracking deals
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setNewPipelineOpen(true)}
|
onClick={() => setNewPipelineOpen(true)}
|
||||||
className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700"
|
className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Create Pipeline
|
Create Pipeline
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<PipelineBoard
|
<PipelineBoard
|
||||||
pipelineId={selectedPipelineId}
|
|
||||||
stages={stages}
|
stages={stages}
|
||||||
onStagesChange={() => loadStages(selectedPipelineId)}
|
deals={deals}
|
||||||
|
onDealMoved={handleDealMoved}
|
||||||
|
onAddDeal={handleAddDeal}
|
||||||
|
onEditDeal={handleEditDeal}
|
||||||
/>
|
/>
|
||||||
|
<PipelineAnalytics stages={stages} deals={deals} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New Pipeline Dialog */}
|
{/* New Pipeline Dialog */}
|
||||||
@ -257,15 +402,14 @@ export default function PipelinesPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={newPipelineName}
|
value={newPipelineName}
|
||||||
onChange={(e) => setNewPipelineName(e.target.value)}
|
onChange={(e) => setNewPipelineName(e.target.value)}
|
||||||
placeholder="e.g., Sales Pipeline"
|
placeholder="e.g., Enterprise Sales"
|
||||||
className="mt-2 bg-slate-800 border-slate-700 text-white"
|
className="mt-2 bg-slate-800 border-slate-700 text-white"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleCreatePipeline();
|
if (e.key === "Enter") handleCreatePipeline();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs text-slate-400">
|
<p className="mt-2 text-xs text-slate-400">
|
||||||
Default stages (Lead, Qualified, Proposal, Negotiation, Won) will
|
Default stages (New Lead → Won) will be created automatically.
|
||||||
be created automatically.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||||
@ -294,9 +438,25 @@ export default function PipelinesPage() {
|
|||||||
onOpenChange={setSettingsOpen}
|
onOpenChange={setSettingsOpen}
|
||||||
pipeline={selectedPipeline}
|
pipeline={selectedPipeline}
|
||||||
stages={stages}
|
stages={stages}
|
||||||
onUpdated={handleRefresh}
|
onPipelinesChanged={refreshPipelines}
|
||||||
|
onStagesChanged={refreshStages}
|
||||||
|
onCreateNewPipeline={() => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
setNewPipelineOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Deal Form (Sheet) */}
|
||||||
|
<DealForm
|
||||||
|
open={dealFormOpen}
|
||||||
|
onOpenChange={setDealFormOpen}
|
||||||
|
deal={editingDeal}
|
||||||
|
pipelineId={selectedPipelineId}
|
||||||
|
stages={stages}
|
||||||
|
defaultStageId={defaultStageId}
|
||||||
|
onSaved={refreshDeals}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue } from '@/types';
|
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Save,
|
Save,
|
||||||
X,
|
X,
|
||||||
|
DollarSign,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ContactDetailViewProps {
|
interface ContactDetailViewProps {
|
||||||
@ -75,6 +76,10 @@ export function ContactDetailView({
|
|||||||
const [savingCustom, setSavingCustom] = useState(false);
|
const [savingCustom, setSavingCustom] = useState(false);
|
||||||
const [loadingCustom, setLoadingCustom] = useState(false);
|
const [loadingCustom, setLoadingCustom] = useState(false);
|
||||||
|
|
||||||
|
// Deals tab
|
||||||
|
const [deals, setDeals] = useState<Deal[]>([]);
|
||||||
|
const [loadingDeals, setLoadingDeals] = useState(false);
|
||||||
|
|
||||||
const fetchContact = useCallback(async () => {
|
const fetchContact = useCallback(async () => {
|
||||||
if (!contactId) return;
|
if (!contactId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -146,14 +151,27 @@ export function ContactDetailView({
|
|||||||
setLoadingCustom(false);
|
setLoadingCustom(false);
|
||||||
}, [contactId, supabase]);
|
}, [contactId, supabase]);
|
||||||
|
|
||||||
|
const fetchDeals = useCallback(async () => {
|
||||||
|
if (!contactId) return;
|
||||||
|
setLoadingDeals(true);
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('deals')
|
||||||
|
.select('*, stage:pipeline_stages(*)')
|
||||||
|
.eq('contact_id', contactId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
setDeals((data ?? []) as Deal[]);
|
||||||
|
setLoadingDeals(false);
|
||||||
|
}, [contactId, supabase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && contactId) {
|
if (open && contactId) {
|
||||||
fetchContact();
|
fetchContact();
|
||||||
fetchTags();
|
fetchTags();
|
||||||
fetchNotes();
|
fetchNotes();
|
||||||
fetchCustomFields();
|
fetchCustomFields();
|
||||||
|
fetchDeals();
|
||||||
}
|
}
|
||||||
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields]);
|
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields, fetchDeals]);
|
||||||
|
|
||||||
async function copyPhone() {
|
async function copyPhone() {
|
||||||
if (!contact) return;
|
if (!contact) return;
|
||||||
@ -389,6 +407,12 @@ export function ContactDetailView({
|
|||||||
>
|
>
|
||||||
Custom Fields
|
Custom Fields
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="deals"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
|
Deals
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Details Tab */}
|
{/* Details Tab */}
|
||||||
@ -594,6 +618,64 @@ export function ContactDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Deals Tab */}
|
||||||
|
<TabsContent value="deals" className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{loadingDeals ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="size-5 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
) : deals.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">No deals yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deals.map((deal) => (
|
||||||
|
<div
|
||||||
|
key={deal.id}
|
||||||
|
className="rounded-lg border border-slate-700 bg-slate-800/50 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{deal.title}
|
||||||
|
</p>
|
||||||
|
{deal.stage && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${deal.stage.color}20`,
|
||||||
|
color: deal.stage.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deal.stage.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<DollarSign className="size-3" />
|
||||||
|
{new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: deal.currency || 'USD',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(deal.value || 0))}
|
||||||
|
</span>
|
||||||
|
{deal.status && deal.status !== 'open' && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
deal.status === 'won'
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deal.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Deal } from "@/types";
|
import type { Deal, PipelineStage } from "@/types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Calendar, Check, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
interface DealCardProps {
|
||||||
ChevronLeft,
|
deal: Deal;
|
||||||
ChevronRight,
|
stage: PipelineStage | null;
|
||||||
Calendar,
|
onEdit: (deal: Deal) => void;
|
||||||
User,
|
isOverlay?: boolean;
|
||||||
DollarSign,
|
}
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
function formatCurrency(value: number, currency?: string) {
|
function formatCurrency(value: number, currency?: string) {
|
||||||
const cur = currency || "USD";
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: cur,
|
currency: currency || "USD",
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(Number(value || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
function formatDate(dateStr: string) {
|
||||||
@ -29,95 +27,87 @@ function formatDate(dateStr: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DealCardProps {
|
function initials(name?: string, fallback?: string) {
|
||||||
deal: Deal;
|
const source = (name || fallback || "?").trim();
|
||||||
onEdit: (deal: Deal) => void;
|
if (!source) return "?";
|
||||||
onMoveStage: (dealId: string, direction: "prev" | "next") => void;
|
return source.charAt(0).toUpperCase();
|
||||||
hasPrev: boolean;
|
|
||||||
hasNext: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DealCard({
|
export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||||
deal,
|
const contactLabel = deal.contact?.name || deal.contact?.phone || "No contact";
|
||||||
onEdit,
|
const assigneeLabel = deal.assignee?.full_name || null;
|
||||||
onMoveStage,
|
|
||||||
hasPrev,
|
|
||||||
hasNext,
|
|
||||||
}: DealCardProps) {
|
|
||||||
const statusColor =
|
|
||||||
deal.status === "won"
|
|
||||||
? "text-emerald-400"
|
|
||||||
: deal.status === "lost"
|
|
||||||
? "text-red-400"
|
|
||||||
: "text-slate-400";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
onClick={() => onEdit(deal)}
|
type="button"
|
||||||
className="group cursor-pointer rounded-lg border border-slate-700/50 bg-slate-800/50 p-3 transition-all hover:border-slate-600 hover:bg-slate-800"
|
onClick={(e) => {
|
||||||
|
// `onClick` still fires after a non-drag tap because the PointerSensor
|
||||||
|
// requires 5px movement before it counts as a drag.
|
||||||
|
if (isOverlay) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(deal);
|
||||||
|
}}
|
||||||
|
className={`group relative w-full cursor-pointer rounded-xl bg-white pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
|
||||||
|
isOverlay
|
||||||
|
? "shadow-xl"
|
||||||
|
: "hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
{/* 4px left accent bar using stage color */}
|
||||||
<h4 className="text-sm font-medium text-white leading-snug truncate">
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute left-0 top-0 h-full w-1 rounded-l-xl"
|
||||||
|
style={{ backgroundColor: stage?.color ?? "#94a3b8" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="flex-1 text-sm font-semibold leading-snug text-gray-900 break-words">
|
||||||
{deal.title}
|
{deal.title}
|
||||||
</h4>
|
</h4>
|
||||||
{deal.status && deal.status !== "open" && (
|
{deal.status === "won" && (
|
||||||
<Badge variant="outline" className={statusColor}>
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700">
|
||||||
{deal.status}
|
<Check className="h-3 w-3" />
|
||||||
</Badge>
|
Won
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deal.status === "lost" && (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-700">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Lost
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
{/* Contact row */}
|
||||||
<DollarSign className="h-3.5 w-3.5 text-emerald-400" />
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-emerald-400">
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-200 text-[10px] font-semibold text-slate-700">
|
||||||
|
{initials(deal.contact?.name, deal.contact?.phone)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-gray-600">{contactLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
{formatCurrency(deal.value, deal.currency)}
|
{formatCurrency(deal.value, deal.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{deal.contact && (
|
|
||||||
<div className="mb-1.5 flex items-center gap-1.5">
|
|
||||||
<User className="h-3 w-3 text-slate-500" />
|
|
||||||
<span className="text-xs text-slate-400 truncate">
|
|
||||||
{deal.contact.name || deal.contact.phone}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deal.expected_close_date && (
|
{deal.expected_close_date && (
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<span className="flex items-center gap-1 text-[11px] text-gray-500">
|
||||||
<Calendar className="h-3 w-3 text-slate-500" />
|
<Calendar className="h-3 w-3" />
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
{formatDate(deal.expected_close_date)}
|
{formatDate(deal.expected_close_date)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assigneeLabel && (
|
||||||
|
<div className="mt-2 flex items-center justify-end">
|
||||||
|
<span
|
||||||
|
title={assigneeLabel}
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-[10px] font-semibold text-emerald-700"
|
||||||
|
>
|
||||||
|
{initials(assigneeLabel)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
disabled={!hasPrev}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMoveStage(deal.id, "prev");
|
|
||||||
}}
|
|
||||||
className="text-slate-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
disabled={!hasNext}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMoveStage(deal.id, "next");
|
|
||||||
}}
|
|
||||||
className="text-slate-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { Deal, Contact, PipelineStage } from "@/types";
|
import type {
|
||||||
|
Contact,
|
||||||
|
Conversation,
|
||||||
|
Deal,
|
||||||
|
DealStatus,
|
||||||
|
PipelineStage,
|
||||||
|
Profile,
|
||||||
|
} from "@/types";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
DialogFooter,
|
} from "@/components/ui/sheet";
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Trash2,
|
||||||
|
MessageSquare,
|
||||||
|
DollarSign,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface DealFormProps {
|
interface DealFormProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -34,51 +50,96 @@ export function DealForm({
|
|||||||
defaultStageId,
|
defaultStageId,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: DealFormProps) {
|
}: DealFormProps) {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [currency, setCurrency] = useState("USD");
|
const [currency, setCurrency] = useState("USD");
|
||||||
const [contactId, setContactId] = useState("");
|
const [contactId, setContactId] = useState("");
|
||||||
const [stageId, setStageId] = useState("");
|
const [stageId, setStageId] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [assignedTo, setAssignedTo] = useState("");
|
||||||
const [expectedCloseDate, setExpectedCloseDate] = useState("");
|
const [expectedCloseDate, setExpectedCloseDate] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
|
const [linkedConversation, setLinkedConversation] =
|
||||||
|
useState<Conversation | null>(null);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [statusAction, setStatusAction] = useState<DealStatus | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const supabase = createClient();
|
// Reset when opening
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) return;
|
||||||
loadContacts();
|
|
||||||
if (deal) {
|
if (deal) {
|
||||||
setTitle(deal.title);
|
setTitle(deal.title);
|
||||||
setValue(String(deal.value));
|
setValue(String(deal.value ?? ""));
|
||||||
setCurrency(deal.currency || "USD");
|
setCurrency(deal.currency || "USD");
|
||||||
setContactId(deal.contact_id);
|
setContactId(deal.contact_id);
|
||||||
setStageId(deal.stage_id);
|
setStageId(deal.stage_id);
|
||||||
setNotes(deal.notes || "");
|
setAssignedTo(deal.assigned_to ?? "");
|
||||||
setExpectedCloseDate(deal.expected_close_date || "");
|
setExpectedCloseDate(deal.expected_close_date ?? "");
|
||||||
|
setNotes(deal.notes ?? "");
|
||||||
} else {
|
} else {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setValue("");
|
setValue("");
|
||||||
setCurrency("USD");
|
setCurrency("USD");
|
||||||
setContactId("");
|
setContactId("");
|
||||||
setStageId(defaultStageId || (stages[0]?.id ?? ""));
|
setStageId(defaultStageId || stages[0]?.id || "");
|
||||||
setNotes("");
|
setAssignedTo("");
|
||||||
setExpectedCloseDate("");
|
setExpectedCloseDate("");
|
||||||
}
|
setNotes("");
|
||||||
}
|
}
|
||||||
}, [open, deal, defaultStageId, stages]);
|
}, [open, deal, defaultStageId, stages]);
|
||||||
|
|
||||||
async function loadContacts() {
|
// Load supporting data once the sheet is open
|
||||||
const { data } = await supabase
|
useEffect(() => {
|
||||||
.from("contacts")
|
if (!open) return;
|
||||||
.select("*")
|
let cancelled = false;
|
||||||
.order("name");
|
(async () => {
|
||||||
if (data) setContacts(data);
|
const [c, p] = await Promise.all([
|
||||||
|
supabase.from("contacts").select("*").order("name"),
|
||||||
|
supabase.from("profiles").select("*").order("full_name"),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setContacts((c.data ?? []) as Contact[]);
|
||||||
|
setProfiles((p.data ?? []) as Profile[]);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, supabase]);
|
||||||
|
|
||||||
|
// Fetch linked conversation for the selected contact (newest open one).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !contactId) {
|
||||||
|
setLinkedConversation(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("conversations")
|
||||||
|
.select("*")
|
||||||
|
.eq("contact_id", contactId)
|
||||||
|
.order("last_message_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (cancelled) return;
|
||||||
|
setLinkedConversation((data as Conversation | null) ?? null);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, contactId, supabase]);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!title.trim() || !contactId || !stageId) return;
|
if (!title.trim() || !contactId || !stageId) {
|
||||||
|
toast.error("Title, contact, and stage are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -88,66 +149,154 @@ export function DealForm({
|
|||||||
contact_id: contactId,
|
contact_id: contactId,
|
||||||
pipeline_id: pipelineId,
|
pipeline_id: pipelineId,
|
||||||
stage_id: stageId,
|
stage_id: stageId,
|
||||||
|
assigned_to: assignedTo || null,
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
expected_close_date: expectedCloseDate || null,
|
expected_close_date: expectedCloseDate || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (deal) {
|
if (deal) {
|
||||||
await supabase.from("deals").update(payload).eq("id", deal.id);
|
const { error } = await supabase
|
||||||
|
.from("deals")
|
||||||
|
.update(payload)
|
||||||
|
.eq("id", deal.id);
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to save deal");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
if (user) {
|
if (!user) {
|
||||||
await supabase
|
toast.error("Not signed in");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { error } = await supabase
|
||||||
.from("deals")
|
.from("deals")
|
||||||
.insert({ ...payload, user_id: user.id });
|
.insert({ ...payload, user_id: user.id, status: "open" });
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to create deal");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
toast.success(deal ? "Deal updated" : "Deal created");
|
||||||
|
onOpenChange(false);
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(status: DealStatus) {
|
||||||
|
if (!deal) return;
|
||||||
|
setStatusAction(status);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("deals")
|
||||||
|
.update({ status })
|
||||||
|
.eq("id", deal.id);
|
||||||
|
setStatusAction(null);
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to update deal status");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
status === "won" ? "Marked as won" : status === "lost" ? "Marked as lost" : "Deal reopened",
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deal) return;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"Delete this deal? This cannot be undone.",
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
setDeleting(true);
|
||||||
|
const { error } = await supabase.from("deals").delete().eq("id", deal.id);
|
||||||
|
setDeleting(false);
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to delete deal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Deal deleted");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700">
|
<SheetContent
|
||||||
<DialogHeader>
|
side="right"
|
||||||
<DialogTitle className="text-white">
|
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<SheetHeader className="border-b border-slate-700/50 p-4">
|
||||||
|
<SheetTitle className="text-white">
|
||||||
{deal ? "Edit Deal" : "New Deal"}
|
{deal ? "Edit Deal" : "New Deal"}
|
||||||
</DialogTitle>
|
</SheetTitle>
|
||||||
</DialogHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 py-2">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-slate-300">Title</Label>
|
<Label className="text-slate-300">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Deal title"
|
placeholder="Deal title"
|
||||||
className="bg-slate-800 border-slate-700 text-white"
|
className="border-slate-700 bg-slate-800 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Contact</Label>
|
||||||
|
<select
|
||||||
|
value={contactId}
|
||||||
|
onChange={(e) => setContactId(e.target.value)}
|
||||||
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a contact</option>
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name || c.phone}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{linkedConversation && (
|
||||||
|
<Link
|
||||||
|
href="/inbox"
|
||||||
|
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-emerald-500/10 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3" />
|
||||||
|
Link to Conversation
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-slate-300">Value</Label>
|
<Label className="text-slate-300">Value</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
className="bg-slate-800 border-slate-700 text-white"
|
className="border-slate-700 bg-slate-800 pl-7 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-slate-300">Currency</Label>
|
<Label className="text-slate-300">Currency</Label>
|
||||||
<select
|
<select
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={(e) => setCurrency(e.target.value)}
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500"
|
||||||
>
|
>
|
||||||
<option value="USD">USD</option>
|
<option value="USD">USD</option>
|
||||||
<option value="EUR">EUR</option>
|
<option value="EUR">EUR</option>
|
||||||
@ -157,19 +306,13 @@ export function DealForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-slate-300">Contact</Label>
|
<Label className="text-slate-300">Expected Close Date</Label>
|
||||||
<select
|
<Input
|
||||||
value={contactId}
|
type="date"
|
||||||
onChange={(e) => setContactId(e.target.value)}
|
value={expectedCloseDate}
|
||||||
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
onChange={(e) => setExpectedCloseDate(e.target.value)}
|
||||||
>
|
className="border-slate-700 bg-slate-800 text-white"
|
||||||
<option value="">Select a contact</option>
|
/>
|
||||||
{contacts.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name || c.phone}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -177,7 +320,7 @@ export function DealForm({
|
|||||||
<select
|
<select
|
||||||
value={stageId}
|
value={stageId}
|
||||||
onChange={(e) => setStageId(e.target.value)}
|
onChange={(e) => setStageId(e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500"
|
||||||
>
|
>
|
||||||
{stages.map((s) => (
|
{stages.map((s) => (
|
||||||
<option key={s.id} value={s.id}>
|
<option key={s.id} value={s.id}>
|
||||||
@ -188,13 +331,19 @@ export function DealForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-slate-300">Expected Close Date</Label>
|
<Label className="text-slate-300">Assigned To</Label>
|
||||||
<Input
|
<select
|
||||||
type="date"
|
value={assignedTo}
|
||||||
value={expectedCloseDate}
|
onChange={(e) => setAssignedTo(e.target.value)}
|
||||||
onChange={(e) => setExpectedCloseDate(e.target.value)}
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500"
|
||||||
className="bg-slate-800 border-slate-700 text-white"
|
>
|
||||||
/>
|
<option value="">Unassigned</option>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.full_name || p.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -203,28 +352,94 @@ export function DealForm({
|
|||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
placeholder="Add notes..."
|
placeholder="Add notes..."
|
||||||
className="bg-slate-800 border-slate-700 text-white min-h-[80px]"
|
className="min-h-[100px] border-slate-700 bg-slate-800 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{deal && (
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-700 bg-slate-900/50 p-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStatusChange("won")}
|
||||||
|
disabled={!!statusAction || deal.status === "won"}
|
||||||
|
className="flex-1 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{statusAction === "won" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="mr-1 h-4 w-4" />
|
||||||
|
Mark as Won
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStatusChange("lost")}
|
||||||
|
disabled={!!statusAction || deal.status === "lost"}
|
||||||
|
className="flex-1 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{statusAction === "lost" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
Mark as Lost
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{deal.status && deal.status !== "open" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleStatusChange("open")}
|
||||||
|
disabled={!!statusAction}
|
||||||
|
className="w-full text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Reopen deal
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
<div className="border-t border-slate-700/50 bg-slate-900/80 p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
className="flex-1 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || !title.trim() || !contactId}
|
disabled={saving || !title.trim() || !contactId || !stageId}
|
||||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
className="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : deal ? "Update Deal" : "Create Deal"}
|
{saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
{deal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
{deleting ? "Deleting..." : "Delete Deal"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import {
|
||||||
import { PipelineStage, Deal } from "@/types";
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
useDroppable,
|
||||||
|
useDraggable,
|
||||||
|
closestCorners,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import type { Deal, PipelineStage } from "@/types";
|
||||||
import { DealCard } from "./deal-card";
|
import { DealCard } from "./deal-card";
|
||||||
import { DealForm } from "./deal-form";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, DollarSign } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface PipelineBoardProps {
|
||||||
|
stages: PipelineStage[];
|
||||||
|
deals: Deal[];
|
||||||
|
onDealMoved: (dealId: string, newStageId: string) => void;
|
||||||
|
onAddDeal: (stageId: string) => void;
|
||||||
|
onEditDeal: (deal: Deal) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number) {
|
function formatCurrency(value: number) {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
@ -17,184 +35,220 @@ function formatCurrency(value: number) {
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PipelineBoardProps {
|
|
||||||
pipelineId: string;
|
|
||||||
stages: PipelineStage[];
|
|
||||||
onStagesChange: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PipelineBoard({
|
export function PipelineBoard({
|
||||||
pipelineId,
|
|
||||||
stages,
|
stages,
|
||||||
onStagesChange,
|
deals,
|
||||||
|
onDealMoved,
|
||||||
|
onAddDeal,
|
||||||
|
onEditDeal,
|
||||||
}: PipelineBoardProps) {
|
}: PipelineBoardProps) {
|
||||||
const [deals, setDeals] = useState<Deal[]>([]);
|
const [activeDealId, setActiveDealId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [dealFormOpen, setDealFormOpen] = useState(false);
|
|
||||||
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
|
|
||||||
const [defaultStageId, setDefaultStageId] = useState<string>("");
|
|
||||||
|
|
||||||
const supabase = createClient();
|
const sortedStages = useMemo(
|
||||||
const sortedStages = [...stages].sort((a, b) => a.position - b.position);
|
() => [...stages].sort((a, b) => a.position - b.position),
|
||||||
|
[stages],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const dealsByStage = useMemo(() => {
|
||||||
if (pipelineId) {
|
const map = new Map<string, Deal[]>();
|
||||||
loadDeals();
|
for (const stage of sortedStages) map.set(stage.id, []);
|
||||||
|
for (const deal of deals) {
|
||||||
|
const bucket = map.get(deal.stage_id);
|
||||||
|
if (bucket) bucket.push(deal);
|
||||||
}
|
}
|
||||||
}, [pipelineId]);
|
return map;
|
||||||
|
}, [sortedStages, deals]);
|
||||||
|
|
||||||
async function loadDeals() {
|
const sensors = useSensors(
|
||||||
setLoading(true);
|
// 5px activation distance avoids clicks being interpreted as drags.
|
||||||
const { data } = await supabase
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
.from("deals")
|
);
|
||||||
.select("*, contact:contacts(*)")
|
|
||||||
.eq("pipeline_id", pipelineId)
|
const activeDeal = activeDealId
|
||||||
.order("created_at", { ascending: false });
|
? deals.find((d) => d.id === activeDealId) ?? null
|
||||||
if (data) setDeals(data);
|
: null;
|
||||||
setLoading(false);
|
|
||||||
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
setActiveDealId(String(event.active.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDealsForStage(stageId: string) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
return deals.filter((d) => d.stage_id === stageId);
|
setActiveDealId(null);
|
||||||
}
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
const dealId = String(active.id);
|
||||||
|
const targetStageId = String(over.id);
|
||||||
|
|
||||||
function getTotalValue(stageId: string) {
|
|
||||||
return getDealsForStage(stageId).reduce((sum, d) => sum + d.value, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddDeal(stageId: string) {
|
|
||||||
setEditingDeal(null);
|
|
||||||
setDefaultStageId(stageId);
|
|
||||||
setDealFormOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditDeal(deal: Deal) {
|
|
||||||
setEditingDeal(deal);
|
|
||||||
setDefaultStageId(deal.stage_id);
|
|
||||||
setDealFormOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMoveStage(dealId: string, direction: "prev" | "next") {
|
|
||||||
const deal = deals.find((d) => d.id === dealId);
|
const deal = deals.find((d) => d.id === dealId);
|
||||||
if (!deal) return;
|
if (!deal || deal.stage_id === targetStageId) return;
|
||||||
|
if (!sortedStages.some((s) => s.id === targetStageId)) return;
|
||||||
|
|
||||||
const currentIndex = sortedStages.findIndex(
|
onDealMoved(dealId, targetStageId);
|
||||||
(s) => s.id === deal.stage_id
|
|
||||||
);
|
|
||||||
const targetIndex =
|
|
||||||
direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
|
||||||
if (targetIndex < 0 || targetIndex >= sortedStages.length) return;
|
|
||||||
|
|
||||||
const targetStage = sortedStages[targetIndex];
|
|
||||||
await supabase
|
|
||||||
.from("deals")
|
|
||||||
.update({ stage_id: targetStage.id })
|
|
||||||
.eq("id", dealId);
|
|
||||||
|
|
||||||
setDeals(
|
|
||||||
deals.map((d) =>
|
|
||||||
d.id === dealId ? { ...d, stage_id: targetStage.id } : d
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
function handleDragCancel() {
|
||||||
return (
|
setActiveDealId(null);
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
||||||
{sortedStages.map((stage) => (
|
|
||||||
<div
|
|
||||||
key={stage.id}
|
|
||||||
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
|
|
||||||
>
|
|
||||||
<div className="p-3 border-b border-slate-800">
|
|
||||||
<div className="h-4 w-24 animate-pulse rounded bg-slate-800" />
|
|
||||||
<div className="mt-2 h-3 w-16 animate-pulse rounded bg-slate-800" />
|
|
||||||
</div>
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-24 animate-pulse rounded-lg bg-slate-800/50"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DndContext
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[calc(100vh-14rem)]">
|
sensors={sensors}
|
||||||
{sortedStages.map((stage, stageIndex) => {
|
collisionDetection={closestCorners}
|
||||||
const stageDeals = getDealsForStage(stage.id);
|
onDragStart={handleDragStart}
|
||||||
const totalValue = getTotalValue(stage.id);
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<div className="pipeline-scroll flex gap-3 overflow-x-auto pb-4">
|
||||||
|
{sortedStages.map((stage) => {
|
||||||
|
const stageDeals = dealsByStage.get(stage.id) ?? [];
|
||||||
|
const totalValue = stageDeals.reduce(
|
||||||
|
(s, d) => s + Number(d.value || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<StageColumn
|
||||||
key={stage.id}
|
key={stage.id}
|
||||||
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
|
stage={stage}
|
||||||
>
|
deals={stageDeals}
|
||||||
{/* Column header */}
|
totalValue={totalValue}
|
||||||
<div className="p-3 border-b border-slate-800">
|
onAddDeal={onAddDeal}
|
||||||
<div className="flex items-center gap-2">
|
onEditDeal={onEditDeal}
|
||||||
<div
|
|
||||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: stage.color }}
|
|
||||||
/>
|
/>
|
||||||
<h3 className="text-sm font-medium text-white truncate">
|
|
||||||
{stage.name}
|
|
||||||
</h3>
|
|
||||||
<span className="ml-auto shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-slate-800 text-xs text-slate-400">
|
|
||||||
{stageDeals.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{totalValue > 0 && (
|
|
||||||
<div className="mt-1.5 flex items-center gap-1 text-xs text-slate-400">
|
|
||||||
<DollarSign className="h-3 w-3" />
|
|
||||||
{formatCurrency(totalValue)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deal cards */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
|
||||||
{stageDeals.map((deal) => (
|
|
||||||
<DealCard
|
|
||||||
key={deal.id}
|
|
||||||
deal={deal}
|
|
||||||
onEdit={handleEditDeal}
|
|
||||||
onMoveStage={handleMoveStage}
|
|
||||||
hasPrev={stageIndex > 0}
|
|
||||||
hasNext={stageIndex < sortedStages.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleAddDeal(stage.id)}
|
|
||||||
className="w-full justify-start text-slate-400 hover:text-white border border-dashed border-slate-700 hover:border-slate-600"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
|
||||||
Add Deal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DealForm
|
<DragOverlay
|
||||||
open={dealFormOpen}
|
dropAnimation={{
|
||||||
onOpenChange={setDealFormOpen}
|
duration: 200,
|
||||||
deal={editingDeal}
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||||
pipelineId={pipelineId}
|
}}
|
||||||
stages={sortedStages}
|
>
|
||||||
defaultStageId={defaultStageId}
|
{activeDeal ? (
|
||||||
onSaved={loadDeals}
|
<div className="opacity-90">
|
||||||
|
<DealCard
|
||||||
|
deal={activeDeal}
|
||||||
|
stage={
|
||||||
|
sortedStages.find((s) => s.id === activeDeal.stage_id) ?? null
|
||||||
|
}
|
||||||
|
onEdit={() => {}}
|
||||||
|
isOverlay
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.pipeline-scroll {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.pipeline-scroll::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pipeline-scroll {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageColumn({
|
||||||
|
stage,
|
||||||
|
deals,
|
||||||
|
totalValue,
|
||||||
|
onAddDeal,
|
||||||
|
onEditDeal,
|
||||||
|
}: {
|
||||||
|
stage: PipelineStage;
|
||||||
|
deals: Deal[];
|
||||||
|
totalValue: number;
|
||||||
|
onAddDeal: (stageId: string) => void;
|
||||||
|
onEditDeal: (deal: Deal) => void;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-w-[280px] max-w-[300px] shrink-0 flex-col rounded-xl p-4"
|
||||||
|
style={{ backgroundColor: "#f9fafb" }}
|
||||||
|
>
|
||||||
|
{/* 3px colored top border (spec) — sits above the column's padding */}
|
||||||
|
<div
|
||||||
|
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
|
||||||
|
style={{ backgroundColor: stage.color }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between pt-3">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-gray-900">
|
||||||
|
{stage.name}
|
||||||
|
</h3>
|
||||||
|
<span className="shrink-0 rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-medium text-gray-700">
|
||||||
|
{deals.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">{formatCurrency(totalValue)}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`mt-3 flex flex-1 flex-col gap-2 rounded-lg transition-all ${
|
||||||
|
isOver
|
||||||
|
? "bg-emerald-50 outline outline-2 outline-dashed outline-emerald-400 outline-offset-2"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{deals.length === 0 ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/40 py-10 text-xs text-gray-400">
|
||||||
|
Drop a deal here
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
deals.map((deal) => (
|
||||||
|
<DraggableDealCard
|
||||||
|
key={deal.id}
|
||||||
|
deal={deal}
|
||||||
|
stage={stage}
|
||||||
|
onEdit={onEditDeal}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAddDeal(stage.id)}
|
||||||
|
className="mt-3 w-full justify-start border border-dashed border-gray-300 bg-transparent text-gray-500 hover:border-gray-400 hover:bg-white hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Add Deal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableDealCard({
|
||||||
|
deal,
|
||||||
|
stage,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
deal: Deal;
|
||||||
|
stage: PipelineStage;
|
||||||
|
onEdit: (deal: Deal) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: deal.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
style={{ opacity: isDragging ? 0.3 : 1, touchAction: "none" }}
|
||||||
|
>
|
||||||
|
<DealCard deal={deal} stage={stage} onEdit={onEdit} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
arrayMove,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { Pipeline, PipelineStage } from "@/types";
|
import type { Pipeline, PipelineStage } from "@/types";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -17,12 +32,12 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const STAGE_COLORS = [
|
const STAGE_COLORS = [
|
||||||
|
"#3b82f6",
|
||||||
"#6366f1",
|
"#6366f1",
|
||||||
"#8b5cf6",
|
"#8b5cf6",
|
||||||
"#ec4899",
|
"#ec4899",
|
||||||
@ -32,7 +47,6 @@ const STAGE_COLORS = [
|
|||||||
"#22c55e",
|
"#22c55e",
|
||||||
"#14b8a6",
|
"#14b8a6",
|
||||||
"#06b6d4",
|
"#06b6d4",
|
||||||
"#3b82f6",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface PipelineSettingsProps {
|
interface PipelineSettingsProps {
|
||||||
@ -40,7 +54,9 @@ interface PipelineSettingsProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
pipeline: Pipeline;
|
pipeline: Pipeline;
|
||||||
stages: PipelineStage[];
|
stages: PipelineStage[];
|
||||||
onUpdated: () => void;
|
onPipelinesChanged: () => void;
|
||||||
|
onStagesChanged: () => void;
|
||||||
|
onCreateNewPipeline: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineSettings({
|
export function PipelineSettings({
|
||||||
@ -48,8 +64,12 @@ export function PipelineSettings({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
pipeline,
|
pipeline,
|
||||||
stages,
|
stages,
|
||||||
onUpdated,
|
onPipelinesChanged,
|
||||||
|
onStagesChanged,
|
||||||
|
onCreateNewPipeline,
|
||||||
}: PipelineSettingsProps) {
|
}: PipelineSettingsProps) {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
const [name, setName] = useState(pipeline.name);
|
const [name, setName] = useState(pipeline.name);
|
||||||
const [localStages, setLocalStages] = useState<PipelineStage[]>(stages);
|
const [localStages, setLocalStages] = useState<PipelineStage[]>(stages);
|
||||||
const [newStageName, setNewStageName] = useState("");
|
const [newStageName, setNewStageName] = useState("");
|
||||||
@ -58,112 +78,132 @@ export function PipelineSettings({
|
|||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const supabase = createClient();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) return;
|
||||||
setName(pipeline.name);
|
setName(pipeline.name);
|
||||||
setLocalStages([...stages].sort((a, b) => a.position - b.position));
|
setLocalStages([...stages].sort((a, b) => a.position - b.position));
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
}
|
|
||||||
}, [open, pipeline, stages]);
|
}, [open, pipeline, stages]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleReorder(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = localStages.findIndex((s) => s.id === active.id);
|
||||||
|
const newIndex = localStages.findIndex((s) => s.id === over.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
setLocalStages(arrayMove(localStages, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
const { error: renameErr } = await supabase
|
||||||
await supabase
|
|
||||||
.from("pipelines")
|
.from("pipelines")
|
||||||
.update({ name: name.trim() })
|
.update({ name: name.trim() })
|
||||||
.eq("id", pipeline.id);
|
.eq("id", pipeline.id);
|
||||||
|
if (renameErr) {
|
||||||
|
toast.error("Failed to rename pipeline");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist each stage's name/color/position
|
||||||
for (let i = 0; i < localStages.length; i++) {
|
for (let i = 0; i < localStages.length; i++) {
|
||||||
const stage = localStages[i];
|
const s = localStages[i];
|
||||||
await supabase
|
await supabase
|
||||||
.from("pipeline_stages")
|
.from("pipeline_stages")
|
||||||
.update({ name: stage.name, color: stage.color, position: i })
|
.update({ name: s.name, color: s.color, position: i })
|
||||||
.eq("id", stage.id);
|
.eq("id", s.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onUpdated();
|
onPipelinesChanged();
|
||||||
|
onStagesChanged();
|
||||||
|
toast.success("Pipeline saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddStage() {
|
async function handleAddStage() {
|
||||||
if (!newStageName.trim()) return;
|
const trimmed = newStageName.trim();
|
||||||
const {
|
if (!trimmed) return;
|
||||||
data: { session },
|
const { data, error } = await supabase
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
const user = session?.user;
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const { data } = await supabase
|
|
||||||
.from("pipeline_stages")
|
.from("pipeline_stages")
|
||||||
.insert({
|
.insert({
|
||||||
pipeline_id: pipeline.id,
|
pipeline_id: pipeline.id,
|
||||||
name: newStageName.trim(),
|
name: trimmed,
|
||||||
color: newStageColor,
|
color: newStageColor,
|
||||||
position: localStages.length,
|
position: localStages.length,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
if (error || !data) {
|
||||||
if (data) {
|
toast.error("Failed to add stage");
|
||||||
setLocalStages([...localStages, data]);
|
return;
|
||||||
setNewStageName("");
|
|
||||||
setNewStageColor(
|
|
||||||
STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
setLocalStages([...localStages, data as PipelineStage]);
|
||||||
|
setNewStageName("");
|
||||||
|
setNewStageColor(STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveStage(stageId: string) {
|
async function handleRemoveStage(stageId: string) {
|
||||||
await supabase.from("pipeline_stages").delete().eq("id", stageId);
|
// Refuse to delete if deals still reference the stage (FK would fail).
|
||||||
setLocalStages(localStages.filter((s) => s.id !== stageId));
|
const { count } = await supabase
|
||||||
|
.from("deals")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.eq("stage_id", stageId);
|
||||||
|
if (count && count > 0) {
|
||||||
|
toast.error("Move or delete deals in this stage first");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const { error } = await supabase
|
||||||
function moveStage(index: number, direction: "up" | "down") {
|
.from("pipeline_stages")
|
||||||
const newStages = [...localStages];
|
.delete()
|
||||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
.eq("id", stageId);
|
||||||
if (targetIndex < 0 || targetIndex >= newStages.length) return;
|
if (error) {
|
||||||
[newStages[index], newStages[targetIndex]] = [
|
toast.error("Failed to delete stage");
|
||||||
newStages[targetIndex],
|
return;
|
||||||
newStages[index],
|
}
|
||||||
];
|
setLocalStages(localStages.filter((s) => s.id !== stageId));
|
||||||
setLocalStages(newStages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeletePipeline() {
|
async function handleDeletePipeline() {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
await supabase.from("deals").delete().eq("pipeline_id", pipeline.id);
|
// ON DELETE CASCADE handles deals + stages.
|
||||||
await supabase
|
const { error } = await supabase
|
||||||
.from("pipeline_stages")
|
.from("pipelines")
|
||||||
.delete()
|
.delete()
|
||||||
.eq("pipeline_id", pipeline.id);
|
.eq("id", pipeline.id);
|
||||||
await supabase.from("pipelines").delete().eq("id", pipeline.id);
|
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to delete pipeline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onUpdated();
|
onPipelinesChanged();
|
||||||
|
toast.success("Pipeline deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700">
|
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700 max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-white">Pipeline Settings</DialogTitle>
|
<DialogTitle className="text-white">Manage Pipeline</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{showDeleteConfirm ? (
|
{showDeleteConfirm ? (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
<div className="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0" />
|
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-red-400">
|
<p className="text-sm font-medium text-red-400">
|
||||||
Delete Pipeline
|
Delete Pipeline
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
This will permanently delete the pipeline, all stages, and all
|
This will archive all deals in this pipeline. This cannot be
|
||||||
deals within it. This cannot be undone.
|
undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -171,12 +211,11 @@ export function PipelineSettings({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
className="border-slate-700 text-slate-300"
|
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeletePipeline}
|
onClick={handleDeletePipeline}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
@ -193,91 +232,67 @@ export function PipelineSettings({
|
|||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="bg-slate-800 border-slate-700 text-white"
|
className="border-slate-700 bg-slate-800 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-slate-300">Stages</Label>
|
<Label className="text-slate-300">Stages</Label>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleReorder}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={localStages.map((s) => s.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{localStages.map((stage, index) => (
|
{localStages.map((stage, index) => (
|
||||||
<div
|
<SortableStageRow
|
||||||
key={stage.id}
|
key={stage.id}
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
|
stage={stage}
|
||||||
>
|
onNameChange={(v) => {
|
||||||
<GripVertical className="h-4 w-4 text-slate-500 shrink-0" />
|
|
||||||
<div
|
|
||||||
className="h-4 w-4 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: stage.color }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={stage.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...localStages];
|
const updated = [...localStages];
|
||||||
updated[index] = {
|
updated[index] = { ...updated[index], name: v };
|
||||||
...updated[index],
|
|
||||||
name: e.target.value,
|
|
||||||
};
|
|
||||||
setLocalStages(updated);
|
setLocalStages(updated);
|
||||||
}}
|
}}
|
||||||
className="h-7 bg-transparent border-transparent text-white text-sm focus:border-slate-600"
|
onColorChange={(v) => {
|
||||||
|
const updated = [...localStages];
|
||||||
|
updated[index] = { ...updated[index], color: v };
|
||||||
|
setLocalStages(updated);
|
||||||
|
}}
|
||||||
|
onRemove={() => handleRemoveStage(stage.id)}
|
||||||
|
colors={STAGE_COLORS}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => moveStage(index, "up")}
|
|
||||||
className="text-slate-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
disabled={index === localStages.length - 1}
|
|
||||||
onClick={() => moveStage(index, "down")}
|
|
||||||
className="text-slate-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<ArrowDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={() => handleRemoveStage(stage.id)}
|
|
||||||
className="text-slate-400 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
{/* Add new stage */}
|
||||||
<div className="flex gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{STAGE_COLORS.map((color) => (
|
{STAGE_COLORS.map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
|
type="button"
|
||||||
onClick={() => setNewStageColor(color)}
|
onClick={() => setNewStageColor(color)}
|
||||||
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
|
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderColor:
|
borderColor:
|
||||||
newStageColor === color
|
newStageColor === color ? "white" : "transparent",
|
||||||
? "white"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
}}
|
||||||
|
aria-label={`Pick color ${color}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={newStageName}
|
value={newStageName}
|
||||||
onChange={(e) => setNewStageName(e.target.value)}
|
onChange={(e) => setNewStageName(e.target.value)}
|
||||||
placeholder="New stage name"
|
placeholder="New stage name"
|
||||||
className="bg-slate-800 border-slate-700 text-white text-sm"
|
className="border-slate-700 bg-slate-800 text-sm text-white"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleAddStage();
|
if (e.key === "Enter") handleAddStage();
|
||||||
}}
|
}}
|
||||||
@ -287,27 +302,36 @@ export function PipelineSettings({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAddStage}
|
onClick={handleAddStage}
|
||||||
disabled={!newStageName.trim()}
|
disabled={!newStageName.trim()}
|
||||||
className="border-slate-700 text-slate-300 shrink-0"
|
className="shrink-0 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCreateNewPipeline}
|
||||||
|
className="w-full border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Create a new pipeline
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
<DialogFooter className="border-slate-700 bg-slate-900/50">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="mr-auto"
|
className="mr-auto bg-red-600 hover:bg-red-700"
|
||||||
>
|
>
|
||||||
Delete Pipeline
|
Delete Pipeline
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@ -325,3 +349,103 @@ export function PipelineSettings({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortableStageRow({
|
||||||
|
stage,
|
||||||
|
onNameChange,
|
||||||
|
onColorChange,
|
||||||
|
onRemove,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
stage: PipelineStage;
|
||||||
|
onNameChange: (v: string) => void;
|
||||||
|
onColorChange: (v: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
colors: string[];
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: stage.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab touch-none text-slate-500 hover:text-slate-300 active:cursor-grabbing"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} />
|
||||||
|
<Input
|
||||||
|
value={stage.name}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
className="h-7 flex-1 border-transparent bg-transparent text-sm text-white focus:border-slate-600"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-slate-400 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorSwatch({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
colors: string[];
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="h-4 w-4 rounded-full border border-slate-600"
|
||||||
|
style={{ backgroundColor: value }}
|
||||||
|
aria-label="Change color"
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute left-0 top-6 z-20 flex flex-wrap gap-1 rounded-lg border border-slate-700 bg-slate-900 p-2 shadow-lg w-36">
|
||||||
|
{colors.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(c);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: c,
|
||||||
|
borderColor: c === value ? "white" : "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -134,6 +134,8 @@ export interface PipelineStage {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DealStatus = 'open' | 'won' | 'lost';
|
||||||
|
|
||||||
export interface Deal {
|
export interface Deal {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@ -141,15 +143,18 @@ export interface Deal {
|
|||||||
stage_id: string;
|
stage_id: string;
|
||||||
contact_id: string;
|
contact_id: string;
|
||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
|
assigned_to?: string;
|
||||||
title: string;
|
title: string;
|
||||||
value: number;
|
value: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
expected_close_date?: string;
|
expected_close_date?: string;
|
||||||
status?: string;
|
status?: DealStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
contact?: Contact;
|
contact?: Contact;
|
||||||
stage?: PipelineStage;
|
stage?: PipelineStage;
|
||||||
|
assignee?: Profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
||||||
|
|||||||
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