feat(admin/ui): enterprise inquiries pipeline

Add /enterprise page wired to /admin/enterprise/inquiries. Sales-style
pipeline: pending → contacted → converted | declined, with an inline
<select> on each row so triage is one click per inquiry rather than a
detail-page round-trip.

Each row shows company, contact, estimated volume, team size, and the
free-form notes from the marketing-site form. Pending is the default
filter so the queue surfaces first; "All" reveals historical decisions.

Added a sidebar entry under Accounts (Briefcase icon) so the inquiry
queue is one click away from the rest of the customer-facing admin.
This commit is contained in:
Matt
2026-05-28 09:52:42 +02:00
parent 3c5fb83e2b
commit 0c206dbfbb
5 changed files with 257 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
// Sales pipeline for inquiries submitted from the marketing site.
// Status flow: pending → contacted → converted | declined.
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { PageHeader } from "@/components/layout/PageHeader";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
listEnterpriseInquiries,
updateEnterpriseInquiry,
} from "@/lib/api/client/admin/enterprise";
import type {
EnterpriseInquiry,
EnterpriseInquiryStatus,
} from "@/lib/api/models/admin";
const STATUS_TONE: Record<EnterpriseInquiryStatus, string> = {
pending: "border-amber-300 text-amber-700 bg-amber-50",
contacted: "border-blue-300 text-blue-700 bg-blue-50",
converted: "border-emerald-300 text-emerald-700 bg-emerald-50",
declined: "border-zinc-300 text-zinc-600 bg-zinc-50",
};
const STATUSES: EnterpriseInquiryStatus[] = [
"pending",
"contacted",
"converted",
"declined",
];
type StatusFilter = EnterpriseInquiryStatus | "all";
export default function EnterprisePage() {
const [status, setStatus] = useState<StatusFilter>("pending");
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "enterprise", "inquiries", status],
queryFn: () => listEnterpriseInquiries(status === "all" ? undefined : status),
staleTime: 30_000,
});
const rows = data?.data ?? [];
return (
<div>
<PageHeader
title="Enterprise inquiries"
description="Talk-to-us submissions from the marketing site. Pipeline: pending → contacted → converted or declined."
>
<StatusToggle value={status} onChange={setStatus} />
</PageHeader>
{isLoading && <Skeleton className="h-32 w-full" />}
{error && (
<div className="text-sm text-red-600 border border-red-200 bg-red-50 rounded-md p-3">
Failed to load inquiries.
</div>
)}
{!isLoading && !error && rows.length === 0 && (
<div className="text-sm text-muted-foreground border border-border rounded-md p-4 bg-card">
No inquiries in this status.
</div>
)}
{rows.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden bg-card">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
<tr>
<th className="text-left px-3 py-2 font-medium">Company</th>
<th className="text-left px-3 py-2 font-medium">Contact</th>
<th className="text-right px-3 py-2 font-medium">Volume</th>
<th className="text-right px-3 py-2 font-medium">Team</th>
<th className="text-left px-3 py-2 font-medium">Notes</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-left px-3 py-2 font-medium">Received</th>
</tr>
</thead>
<tbody>
{rows.map((i) => (
<InquiryRow key={i.id} inquiry={i} />
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function InquiryRow({ inquiry }: { inquiry: EnterpriseInquiry }) {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (next: EnterpriseInquiryStatus) =>
updateEnterpriseInquiry(inquiry.id, { status: next }),
onSuccess: () => {
toast.success("Inquiry updated");
qc.invalidateQueries({ queryKey: ["admin", "enterprise"] });
},
onError: (err: Error) => toast.error(err.message || "Update failed"),
});
return (
<tr className="border-t border-border align-top">
<td className="px-3 py-2">
<div className="font-medium">{inquiry.company_name}</div>
</td>
<td className="px-3 py-2 text-xs">
<div>{inquiry.contact_name}</div>
<div className="text-muted-foreground">{inquiry.contact_email}</div>
</td>
<td className="px-3 py-2 text-right tabular-nums text-xs">
{inquiry.estimated_volume != null
? inquiry.estimated_volume.toLocaleString()
: "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-xs">
{inquiry.team_size != null ? inquiry.team_size : "—"}
</td>
<td className="px-3 py-2 text-xs max-w-md truncate" title={inquiry.notes}>
{inquiry.notes || <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2">
<select
value={inquiry.status}
onChange={(e) =>
mutation.mutate(e.target.value as EnterpriseInquiryStatus)
}
disabled={mutation.isPending}
className={`text-[10px] px-1.5 py-1 rounded border ${
STATUS_TONE[inquiry.status]
} font-medium`}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(inquiry.created_at).toLocaleDateString()}
</td>
</tr>
);
}
function StatusToggle({
value,
onChange,
}: {
value: StatusFilter;
onChange: (v: StatusFilter) => void;
}) {
const options: { value: StatusFilter; label: string }[] = [
{ value: "pending", label: "Pending" },
{ value: "contacted", label: "Contacted" },
{ value: "converted", label: "Converted" },
{ value: "declined", label: "Declined" },
{ value: "all", label: "All" },
];
return (
<div className="inline-flex rounded-md border border-border bg-card p-0.5 text-xs">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`px-2 py-1 rounded ${
value === opt.value
? "bg-[var(--admin-accent)] text-white"
: "text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+2
View File
@@ -7,6 +7,7 @@ import { NavLink } from "react-router-dom";
import {
Activity,
BarChart3,
Briefcase,
Building2,
Cloud,
Cog,
@@ -66,6 +67,7 @@ const GROUPS: NavGroup[] = [
{ to: "/users", label: "Users", icon: Users },
{ to: "/organizations", label: "Organizations", icon: Building2 },
{ to: "/plans", label: "Plans & Billing", icon: CreditCard },
{ to: "/enterprise", label: "Enterprise", icon: Briefcase },
],
},
{
@@ -0,0 +1,41 @@
// /admin/enterprise/inquiries — sales-pipeline triage.
import { Request } from "@/lib/api/client";
import type {
EnterpriseInquiry,
UpdateEnterpriseInquiryRequest,
} from "@/lib/api/models/admin";
export function listEnterpriseInquiries(
status?: string,
limit = 50,
): Promise<{ data: EnterpriseInquiry[] }> {
const usp = new URLSearchParams();
if (status) usp.set("status", status);
usp.set("limit", String(limit));
return Request({
method: "GET",
url: `/admin/enterprise/inquiries?${usp.toString()}`,
authorization: true,
});
}
export function getEnterpriseInquiry(id: string): Promise<EnterpriseInquiry> {
return Request({
method: "GET",
url: `/admin/enterprise/inquiries/${id}`,
authorization: true,
});
}
export function updateEnterpriseInquiry(
id: string,
body: UpdateEnterpriseInquiryRequest,
): Promise<EnterpriseInquiry> {
return Request({
method: "PATCH",
url: `/admin/enterprise/inquiries/${id}`,
authorization: true,
data: body,
});
}
+29
View File
@@ -323,6 +323,35 @@ export interface ProvisioningJobCreate {
};
}
// /admin/enterprise/inquiries — sales pipeline for "talk to us"
// requests submitted from the marketing site.
export type EnterpriseInquiryStatus =
| "pending"
| "contacted"
| "converted"
| "declined";
export interface EnterpriseInquiry {
id: string;
company_name: string;
contact_name: string;
contact_email: string;
estimated_volume?: number | null;
team_size?: number | null;
notes: string;
status: EnterpriseInquiryStatus;
created_at: string;
processed_at?: string | null;
processed_by?: string | null;
}
export interface UpdateEnterpriseInquiryRequest {
status?: EnterpriseInquiryStatus;
assigned_to?: string;
notes?: string;
}
// /admin/campaigns/* — platform-wide campaign admin (force-stop runaway
// campaigns, inspect engagement counters per campaign).
+2
View File
@@ -39,6 +39,7 @@ import UsersPage from "@/app/dashboard/UsersPage";
import UserDetailPage from "@/app/dashboard/UserDetailPage";
import WarmupPage from "@/app/dashboard/WarmupPage";
import CampaignsPage from "@/app/dashboard/CampaignsPage";
import EnterprisePage from "@/app/dashboard/EnterprisePage";
import {
AnalyticsPage,
MailboxesPage,
@@ -91,6 +92,7 @@ const router = createBrowserRouter([
{ path: "plans", element: <PlansPage /> },
{ path: "warmup", element: <WarmupPage /> },
{ path: "campaigns", element: <CampaignsPage /> },
{ path: "enterprise", element: <EnterprisePage /> },
{ path: "analytics", element: <AnalyticsPage /> },
{ path: "audit", element: <AuditPage /> },
{