mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
PR 9 of the multi-user accounts series — the final piece. Visual
polish so non-owners see consistent disabled states across the
app and so every user always knows which account they're acting in.
Sidebar
- New account-name strip above the user dropdown, gated behind
the `account_sharing` beta flag (same flag the Members tab
from PR 6 reads). For solo users (the default) the account is
named after them, so showing it would just duplicate the user
name below — flag-gating keeps the original layout untouched
for single-user installs.
- When the caller's role is admin/agent/viewer (not owner), the
role is surfaced as a small uppercase chip alongside the
account name so they have an unambiguous signal of their
permission level without opening Settings.
- Nav items stay always-visible per the planning doc's
"show-but-disable" decision — hiding nav rows by role would
make the app feel broken.
Inbox composer
- Send button + Templates button + textarea are disabled for
viewers with a "Read-only — viewers can browse but not reply"
placeholder and tooltip. The existing session-expired branch
still wins when it applies (session-expired is the more
specific condition and surfaces template-only sending).
List-page create buttons
- broadcasts/page.tsx: "New Broadcast" CTA (header + empty
state).
- automations/page.tsx: "Create Automation" CTA.
- flows/page.tsx: "New flow" CTA (header + empty-state).
- contacts/page.tsx: "Add Contact" + "Import" CTAs.
- pipelines/page.tsx: "Add Pipeline" (gates on edit-settings —
pipelines are settings-class under the 017 RLS) and "Add
Deal" (gates on send-messages).
All use `useCan(...)` from PR 5. Disabled buttons get a tooltip
explaining why (`title="Read-only — your role can't ..."`).
Solo users see no visual change — single-owner accounts pass
every capability check.
Why show-but-disable, not hide
- A viewer scanning the app should still see what's possible
in the product, both to understand what they can ask for an
upgrade for and to avoid the UI feeling silently broken
("the New button just disappeared?"). The tooltip carries
the explanation cheaply.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import type { Pipeline, PipelineStage, Deal } from "@/types";
|
|
import { PipelineBoard } from "@/components/pipelines/pipeline-board";
|
|
import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
|
|
import { DealForm } from "@/components/pipelines/deal-form";
|
|
import { PipelineAnalytics } from "@/components/pipelines/pipeline-analytics";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useCan } from "@/hooks/use-can";
|
|
|
|
// Pipeline creation is admin-class (it's a settings-tier write
|
|
// under the new RLS), but deal creation is operational and only
|
|
// requires agent+. The two CTAs gate on different capabilities.
|
|
const PIPELINE_READ_ONLY_TITLE =
|
|
"Read-only — your role can't create pipelines";
|
|
const DEAL_READ_ONLY_TITLE = "Read-only — your role can't create deals";
|
|
|
|
// Spec-defined seed — name and color per the product spec.
|
|
const SPEC_DEFAULT_STAGES = [
|
|
{ name: "New Lead", color: "#3b82f6", position: 0 }, // blue
|
|
{ name: "Qualified", color: "#eab308", position: 1 }, // yellow
|
|
{ name: "Proposal Sent", color: "#f97316", position: 2 }, // orange
|
|
{ name: "Negotiation", color: "#8b5cf6", position: 3 }, // purple
|
|
{ name: "Won", color: "#22c55e", position: 4 }, // green
|
|
];
|
|
|
|
export default function PipelinesPage() {
|
|
const supabase = createClient();
|
|
const canEditSettings = useCan("edit-settings");
|
|
const canCreateDeals = useCan("send-messages");
|
|
|
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
|
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
|
|
const [stages, setStages] = useState<PipelineStage[]>([]);
|
|
const [deals, setDeals] = useState<Deal[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Dialog / sheet state
|
|
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
|
|
const [newPipelineName, setNewPipelineName] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
// 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>("");
|
|
|
|
// Guard against double-seeding (React StrictMode double-effect in dev).
|
|
const seedAttempted = useRef(false);
|
|
|
|
const loadPipelines = useCallback(async () => {
|
|
const { data, error } = await supabase
|
|
.from("pipelines")
|
|
.select("*")
|
|
.order("created_at");
|
|
if (error) {
|
|
console.error("Failed to load pipelines:", error.message);
|
|
return [];
|
|
}
|
|
return data ?? [];
|
|
}, [supabase]);
|
|
|
|
const loadStages = useCallback(
|
|
async (pipelineId: string) => {
|
|
const { data } = await supabase
|
|
.from("pipeline_stages")
|
|
.select("*")
|
|
.eq("pipeline_id", pipelineId)
|
|
.order("position");
|
|
return data ?? [];
|
|
},
|
|
[supabase],
|
|
);
|
|
|
|
const loadDeals = useCallback(
|
|
async (pipelineId: string) => {
|
|
const { data } = await supabase
|
|
.from("deals")
|
|
.select("*, contact:contacts(*), assignee:profiles!deals_assigned_to_fkey(*)")
|
|
.eq("pipeline_id", pipelineId)
|
|
.order("created_at", { ascending: false });
|
|
return (data ?? []) as Deal[];
|
|
},
|
|
[supabase],
|
|
);
|
|
|
|
const seedDefaultPipeline = useCallback(async (): Promise<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.
|
|
// Clearing on no-selection is a legitimate sync with URL/prop
|
|
// state; the load completion uses async setters inside promise
|
|
// callbacks (not synchronous in the effect body).
|
|
useEffect(() => {
|
|
if (!selectedPipelineId) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setStages([]);
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setDeals([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
const [s, d] = await Promise.all([
|
|
loadStages(selectedPipelineId),
|
|
loadDeals(selectedPipelineId),
|
|
]);
|
|
if (cancelled) return;
|
|
setStages(s);
|
|
setDeals(d);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedPipelineId, loadStages, loadDeals]);
|
|
|
|
const refreshPipelines = useCallback(async () => {
|
|
const list = await loadPipelines();
|
|
setPipelines(list);
|
|
if (list.length === 0) setSelectedPipelineId("");
|
|
else if (!list.some((p) => p.id === selectedPipelineId))
|
|
setSelectedPipelineId(list[0].id);
|
|
}, [loadPipelines, selectedPipelineId]);
|
|
|
|
const refreshStages = useCallback(async () => {
|
|
if (!selectedPipelineId) return;
|
|
setStages(await loadStages(selectedPipelineId));
|
|
}, [loadStages, selectedPipelineId]);
|
|
|
|
const refreshDeals = useCallback(async () => {
|
|
if (!selectedPipelineId) return;
|
|
setDeals(await loadDeals(selectedPipelineId));
|
|
}, [loadDeals, selectedPipelineId]);
|
|
|
|
const handleDealMoved = useCallback(
|
|
async (dealId: string, newStageId: string) => {
|
|
// Optimistic update — board already animated; just persist.
|
|
setDeals((prev) =>
|
|
prev.map((d) => (d.id === dealId ? { ...d, stage_id: newStageId } : d)),
|
|
);
|
|
const { error } = await supabase
|
|
.from("deals")
|
|
.update({ stage_id: newStageId })
|
|
.eq("id", dealId);
|
|
if (error) {
|
|
toast.error("Failed to move deal");
|
|
refreshDeals();
|
|
}
|
|
},
|
|
[supabase, refreshDeals],
|
|
);
|
|
|
|
const handleAddDeal = useCallback(
|
|
(stageId?: string) => {
|
|
setEditingDeal(null);
|
|
setDefaultStageId(stageId ?? stages[0]?.id ?? "");
|
|
setDealFormOpen(true);
|
|
},
|
|
[stages],
|
|
);
|
|
|
|
const handleEditDeal = useCallback((deal: Deal) => {
|
|
setEditingDeal(deal);
|
|
setDefaultStageId(deal.stage_id);
|
|
setDealFormOpen(true);
|
|
}, []);
|
|
|
|
async function handleCreatePipeline() {
|
|
const name = newPipelineName.trim();
|
|
if (!name) return;
|
|
setCreating(true);
|
|
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession();
|
|
const user = session?.user;
|
|
if (!user) {
|
|
setCreating(false);
|
|
return;
|
|
}
|
|
|
|
const { data: pipeline, error } = await supabase
|
|
.from("pipelines")
|
|
.insert({ user_id: user.id, name })
|
|
.select()
|
|
.single();
|
|
|
|
if (error || !pipeline) {
|
|
toast.error("Failed to create pipeline");
|
|
setCreating(false);
|
|
return;
|
|
}
|
|
|
|
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
|
|
pipeline_id: pipeline.id,
|
|
name: s.name,
|
|
color: s.color,
|
|
position: s.position,
|
|
}));
|
|
await supabase.from("pipeline_stages").insert(stagesPayload);
|
|
|
|
setNewPipelineName("");
|
|
setNewPipelineOpen(false);
|
|
setSelectedPipelineId(pipeline.id);
|
|
await refreshPipelines();
|
|
setCreating(false);
|
|
toast.success("Pipeline created");
|
|
}
|
|
|
|
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="h-8 w-48 animate-pulse rounded bg-slate-800" />
|
|
<div className="h-9 w-28 animate-pulse rounded-lg bg-slate-800" />
|
|
</div>
|
|
<div className="flex gap-3">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
{/* Pipeline selector dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors data-[popup-open]:bg-slate-800"
|
|
>
|
|
<GitBranch className="h-4 w-4 text-primary" />
|
|
<span className="font-semibold">
|
|
{selectedPipeline?.name ?? "Select Pipeline"}
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 text-slate-400" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="w-64 border-slate-700 bg-slate-900 text-slate-200"
|
|
>
|
|
{pipelines.length === 0 && (
|
|
<DropdownMenuItem disabled className="text-slate-500">
|
|
No pipelines yet
|
|
</DropdownMenuItem>
|
|
)}
|
|
{pipelines.map((p) => (
|
|
<DropdownMenuItem
|
|
key={p.id}
|
|
onClick={() => setSelectedPipelineId(p.id)}
|
|
className={
|
|
p.id === selectedPipelineId
|
|
? "text-primary"
|
|
: "text-slate-300"
|
|
}
|
|
>
|
|
<GitBranch className="mr-2 h-3.5 w-3.5" />
|
|
{p.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator className="bg-slate-700" />
|
|
{selectedPipeline && (
|
|
<DropdownMenuItem
|
|
onClick={() => setSettingsOpen(true)}
|
|
className="text-slate-300"
|
|
>
|
|
<Settings className="mr-2 h-3.5 w-3.5" />
|
|
Manage Pipelines
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setNewPipelineOpen(true)}
|
|
disabled={!canEditSettings}
|
|
title={canEditSettings ? undefined : PIPELINE_READ_ONLY_TITLE}
|
|
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={
|
|
!canCreateDeals || !selectedPipelineId || stages.length === 0
|
|
}
|
|
title={canCreateDeals ? undefined : DEAL_READ_ONLY_TITLE}
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
Add Deal
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Board */}
|
|
{pipelines.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20">
|
|
<GitBranch className="h-12 w-12 text-slate-600" />
|
|
<h3 className="mt-4 text-lg font-medium text-white">
|
|
No pipelines yet
|
|
</h3>
|
|
<p className="mt-2 text-sm text-slate-400">
|
|
Create a pipeline to start tracking deals
|
|
</p>
|
|
<Button
|
|
onClick={() => setNewPipelineOpen(true)}
|
|
disabled={!canEditSettings}
|
|
title={canEditSettings ? undefined : PIPELINE_READ_ONLY_TITLE}
|
|
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
Create Pipeline
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<PipelineAnalytics stages={stages} deals={deals} />
|
|
<PipelineBoard
|
|
stages={stages}
|
|
deals={deals}
|
|
onDealMoved={handleDealMoved}
|
|
onAddDeal={handleAddDeal}
|
|
onEditDeal={handleEditDeal}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* New Pipeline Dialog */}
|
|
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
|
|
<DialogContent className="sm:max-w-sm bg-slate-900 border-slate-700">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-white">New Pipeline</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="py-2">
|
|
<Label className="text-slate-300">Pipeline Name</Label>
|
|
<Input
|
|
value={newPipelineName}
|
|
onChange={(e) => setNewPipelineName(e.target.value)}
|
|
placeholder="e.g., Enterprise Sales"
|
|
className="mt-2 bg-slate-800 border-slate-700 text-white"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleCreatePipeline();
|
|
}}
|
|
/>
|
|
<p className="mt-2 text-xs text-slate-400">
|
|
Default stages (New Lead → Won) will be created automatically.
|
|
</p>
|
|
</div>
|
|
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setNewPipelineOpen(false)}
|
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreatePipeline}
|
|
disabled={creating || !newPipelineName.trim()}
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
{creating ? "Creating..." : "Create Pipeline"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Pipeline Settings */}
|
|
{selectedPipeline && (
|
|
<PipelineSettings
|
|
open={settingsOpen}
|
|
onOpenChange={setSettingsOpen}
|
|
pipeline={selectedPipeline}
|
|
stages={stages}
|
|
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>
|
|
);
|
|
}
|