Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions client/src/pages/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { BarChart3, Calendar, Plus, Users, Video, Upload, ChevronDown, PenSquare, Loader2, Eye, ImageIcon, CheckCircle, Trash2, Settings, Image as PhotoIcon, Clapperboard, ExternalLink, Music, Wand2, User, MoreHorizontal, Globe, MessageCircle, TrendingUp, Layers, ArrowLeft, Shield, Crown, Sparkles, Gift, Clock, X, UserX, UserCheck, AlertTriangle, Ban, History, Database, HardDrive, FileText, Link2, Copy, Filter, BookOpen, Megaphone, UserPlus, Briefcase, Mic2, LayoutGrid, LayoutList, ArrowUpDown, FolderOpen, ClipboardCopy, CheckSquare, Square, Mail, Activity } from "lucide-react";
import { BarChart3, Calendar, Plus, Users, Video, Upload, ChevronDown, PenSquare, Loader2, Eye, ImageIcon, CheckCircle, Trash2, Settings, Image as PhotoIcon, Clapperboard, ExternalLink, Music, Wand2, User, MoreHorizontal, Globe, MessageCircle, TrendingUp, Layers, ArrowLeft, Shield, Crown, Sparkles, Gift, Clock, X, UserX, UserCheck, AlertTriangle, Ban, History, Database, HardDrive, FileText, Link2, Copy, Filter, BookOpen, Megaphone, UserPlus, Briefcase, Mic2, LayoutGrid, LayoutList, ArrowUpDown, FolderOpen, ClipboardCopy, CheckSquare, Square, Mail, Activity, Map } from "lucide-react";
import GlobalNav from "@/components/GlobalNav";
import {
Select,
Expand Down Expand Up @@ -75,12 +75,13 @@ import {
ALL_STATUSES,
} from "@/data/landingPageRegistry";

// Lazy-load enrichment health page and pulse dashboard
// Lazy-load enrichment health page, pulse dashboard, and pathways
import { lazy, Suspense } from "react";
const EnrichmentHealthPage = lazy(() => import("@/pages/admin/EnrichmentHealthPage"));
const PulseDashboard = lazy(() => import("@/pages/admin/PulseDashboard"));
const PathwaysAdminPage = lazy(() => import("@/pages/admin/PathwaysPage"));

type AdminTab = 'overview' | 'users' | 'leads' | 'enquiries' | 'music' | 'voices' | 'landing-pages' | 'modules' | 'enrichment' | 'pulse' | 'wrappers';
type AdminTab = 'overview' | 'users' | 'leads' | 'enquiries' | 'music' | 'voices' | 'landing-pages' | 'modules' | 'enrichment' | 'pulse' | 'wrappers' | 'pathways';

function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
Expand Down Expand Up @@ -2676,6 +2677,10 @@ export default function Admin() {
<Layers className="w-4 h-4 mr-2" />
Wrappers
</TabsTrigger>
<TabsTrigger value="pathways" className="data-[state=active]:bg-muted text-sm px-3" data-testid="tab-pathways">
<Map className="w-4 h-4 mr-2" />
Pathways
</TabsTrigger>
</TabsList>
</div>

Expand Down Expand Up @@ -2734,6 +2739,12 @@ export default function Admin() {
</a>
</div>
</TabsContent>

<TabsContent value="pathways">
<Suspense fallback={<div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>}>
<PathwaysAdminPage />
</Suspense>
</TabsContent>
</Tabs>
</div>
</div>
Expand Down
209 changes: 205 additions & 4 deletions client/src/pages/CrmPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import {
MessageCircle,
Copy,
Bot,
Map,
Lock,
Unlock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Expand Down Expand Up @@ -97,7 +100,7 @@ import { ContactDossierContext } from "@/components/crm/ContactDossierContext";
import { HeartDot, HeartBadge } from "@/components/shh/HeartScore";
import { HeartTimeline } from "@/components/shh/HeartTimeline";
import TrustOMeter from "@/components/crm/TrustOMeter";
import type { CrmContact, CrmDispatchLog, CrmEngagementView, CrmPipelineStage, CrmNudge, CrmPipelineEvent, CrmType } from "@shared/schema";
import type { CrmContact, CrmDispatchLog, CrmEngagementView, CrmPipelineStage, CrmNudge, CrmPipelineEvent, CrmType, Pathway } from "@shared/schema";
import { CRM_TYPE_CONFIG } from "@shared/schema";
import type { AgentInfo } from "@/components/crm/AgentBriefingModal";

Expand Down Expand Up @@ -646,6 +649,7 @@ function ContactListItem({
{stageConfig.label}
</Badge>
)}
<ContactPathwayBadge pathwayId={contact.pathwayId} />
{/* Quick actions on hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
Expand Down Expand Up @@ -1126,6 +1130,9 @@ function ContactDetail({
</div>
</div>

{/* ─── Pathway Section ─── */}
<ContactPathwaySection contact={contact} />

{/* ─── Commercial Activity (Work Orders & Invoices) ─── */}
<ContactCommercialSection contactId={contact.id} />

Expand Down Expand Up @@ -1884,11 +1891,23 @@ export default function CrmPage() {
const [originFilter, setOriginFilter] = useState<OriginFilter>("all");
const [engagementFilter, setEngagementFilter] = useState<EngagementFilter>("all");
const [stageFilter, setStageFilter] = useState<string>("all");
const [pathwayFilter, setPathwayFilter] = useState<string>("all");
const [sortField, setSortField] = useState<SortField>("dateAdded");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");

const isInCrmMode = location.startsWith('/crm/');

// Fetch pathways list for filter dropdown
const { data: pathwaysForFilter } = useQuery<Pathway[]>({
queryKey: ["/api/pathways", "active"],
queryFn: async () => {
const res = await fetch("/api/pathways?status=active", { credentials: "include" });
if (!res.ok) return [];
return res.json();
},
staleTime: 5 * 60 * 1000,
});

const {
data: contacts,
isLoading,
Expand Down Expand Up @@ -1926,6 +1945,15 @@ export default function CrmPage() {
result = result.filter(c => c.pipelineStage === stageFilter);
}

// Pathway filter
if (pathwayFilter !== "all") {
if (pathwayFilter === "none") {
result = result.filter(c => !c.pathwayId);
} else {
result = result.filter(c => c.pathwayId === pathwayFilter);
}
}

// Sort
result.sort((a, b) => {
let comparison = 0;
Expand All @@ -1950,9 +1978,9 @@ export default function CrmPage() {
});

return result;
}, [contacts, originFilter, engagementFilter, stageFilter, sortField, sortDirection]);
}, [contacts, originFilter, engagementFilter, stageFilter, pathwayFilter, sortField, sortDirection]);

const hasActiveFilters = originFilter !== "all" || engagementFilter !== "all" || stageFilter !== "all";
const hasActiveFilters = originFilter !== "all" || engagementFilter !== "all" || stageFilter !== "all" || pathwayFilter !== "all";

const createMutation = useMutation({
mutationFn: async (data: ContactFormData) => {
Expand Down Expand Up @@ -2167,12 +2195,26 @@ export default function CrmPage() {
</SelectContent>
</Select>

{/* Pathway filter */}
<Select value={pathwayFilter} onValueChange={setPathwayFilter}>
<SelectTrigger className="h-7 w-auto text-[11px] bg-white/[0.03] border-white/[0.06] px-2 gap-1">
<SelectValue placeholder="Pathway" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">All Pathways</SelectItem>
<SelectItem value="none" className="text-xs">No Pathway</SelectItem>
{pathwaysForFilter?.map(p => (
<SelectItem key={p.id} value={p.id} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>

{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-white/40 hover:text-white"
onClick={() => { setOriginFilter("all"); setEngagementFilter("all"); setStageFilter("all"); }}
onClick={() => { setOriginFilter("all"); setEngagementFilter("all"); setStageFilter("all"); setPathwayFilter("all"); }}
>
<X className="w-3 h-3 mr-1" />
Clear
Expand Down Expand Up @@ -2293,6 +2335,165 @@ export default function CrmPage() {
);
}

// ─── Contact Pathway Badge (for list view) ───────────────────────────────────

function ContactPathwayBadge({ pathwayId }: { pathwayId: string | null | undefined }) {
const { data: pathwaysList } = useQuery<Pathway[]>({
queryKey: ["/api/pathways", "active"],
queryFn: async () => {
const res = await fetch("/api/pathways?status=active", { credentials: "include" });
if (!res.ok) return [];
return res.json();
},
staleTime: 5 * 60 * 1000,
});

if (!pathwayId || !pathwaysList) return null;
const pathway = pathwaysList.find((p) => p.id === pathwayId);
if (!pathway) return null;

return (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full border font-medium"
style={{
borderColor: `${pathway.colour || "#95A5A6"}50`,
color: pathway.colour || "#95A5A6",
}}
>
{pathway.name}
</span>
);
}

// ─── Contact Pathway Section ──────────────────────────────────────────────────

function ContactPathwaySection({ contact }: { contact: CrmContact }) {
const queryClient = useQueryClient();
const { toast } = useToast();

const { data: pathwaysList } = useQuery<Pathway[]>({
queryKey: ["/api/pathways", "active"],
queryFn: async () => {
const res = await fetch("/api/pathways?status=active", { credentials: "include" });
if (!res.ok) return [];
return res.json();
},
staleTime: 5 * 60 * 1000,
});

const assignMutation = useMutation({
mutationFn: async (data: { pathwayId: string | null; pathwayStage?: string | null; pathwayLocked?: boolean }) => {
if (data.pathwayId === null) {
return apiRequest("DELETE", `/api/pathways/contacts/${contact.id}/pathway`);
}
return apiRequest("PUT", `/api/pathways/contacts/${contact.id}/pathway`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/crm/contacts"] });
toast({ title: "Pathway updated" });
},
onError: (err: Error) => {
toast({ title: "Failed to update pathway", description: err.message, variant: "destructive" });
},
});

const currentPathway = pathwaysList?.find((p) => p.id === contact.pathwayId);
const stages = currentPathway ? (currentPathway.stageConfig as any[] || []).sort((a: any, b: any) => a.order - b.order) : [];

return (
<div>
<h4 className="text-[11px] uppercase tracking-[0.08em] font-medium text-white/35 mb-2 flex items-center gap-1.5">
<Map className="w-3 h-3" />
Pathway
</h4>
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-3">
{/* Current pathway badge */}
{currentPathway && (
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: currentPathway.colour || "#95A5A6" }}
/>
<span className="text-sm font-medium text-white/80">{currentPathway.name}</span>
{contact.pathwayLocked ? (
<Lock className="w-3 h-3 text-amber-400/60" />
) : (
<Unlock className="w-3 h-3 text-white/20" />
)}
</div>
)}

{/* Pathway selector */}
<div className="flex items-center gap-2">
<select
value={contact.pathwayId || ""}
onChange={(e) => {
const pathwayId = e.target.value || null;
if (pathwayId) {
const pathway = pathwaysList?.find((p) => p.id === pathwayId);
const firstStage = pathway ? ((pathway.stageConfig as any[]) || [])[0]?.id : null;
assignMutation.mutate({ pathwayId, pathwayStage: firstStage });
} else {
assignMutation.mutate({ pathwayId: null });
}
}}
className="flex-1 bg-white/[0.03] border border-white/[0.06] rounded-lg px-2.5 py-1.5 text-xs text-white"
>
<option value="">No pathway</option>
{pathwaysList?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>

{/* Lock toggle */}
{contact.pathwayId && (
<button
onClick={() => {
assignMutation.mutate({
pathwayId: contact.pathwayId!,
pathwayStage: contact.pathwayStage,
pathwayLocked: !contact.pathwayLocked,
});
}}
className="p-1.5 rounded-lg hover:bg-white/[0.05] transition-colors"
title={contact.pathwayLocked ? "Unlock pathway (allow inference)" : "Lock pathway (prevent inference)"}
>
{contact.pathwayLocked ? (
<Lock className="w-3.5 h-3.5 text-amber-400/70" />
) : (
<Unlock className="w-3.5 h-3.5 text-white/30" />
)}
</button>
)}
</div>

{/* Stage selector */}
{contact.pathwayId && stages.length > 0 && (
<div>
<span className="text-[10px] uppercase tracking-wider text-white/25 font-medium">Stage</span>
<select
value={contact.pathwayStage || ""}
onChange={(e) => {
assignMutation.mutate({
pathwayId: contact.pathwayId!,
pathwayStage: e.target.value || null,
pathwayLocked: contact.pathwayLocked ?? false,
});
}}
className="w-full mt-1 bg-white/[0.03] border border-white/[0.06] rounded-lg px-2.5 py-1.5 text-xs text-white"
>
<option value="">No stage</option>
{stages.map((s: any) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
)}
</div>
</div>
);
}

// ─── Agent Activity Section (Norman/Nora timeline in contact detail) ─────────

// ─── Schedule Follow-Up Button ────────────────────────────────────────────────
Expand Down
Loading