Admin panel
This commit is contained in:
parent
5390028a5c
commit
44fa0ef2a7
17 changed files with 977 additions and 96 deletions
7
admin-token-command.fish
Normal file
7
admin-token-command.fish
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
set -x ADMIN_TOKEN_SIGNING_SECRET 'Y/NRcCZFluKtSjKgxixDHgeFF0h24B8EdWYLjeviLRYJnnpDpJaphIuQBqfSx3I0NQDC5PqeZ8Z7aJDQAlMp9Q=='
|
||||
set -x ADMIN_TOKEN_AUDIENCE 'xtablo-admin'
|
||||
set -x ADMIN_OPERATOR_EMAIL 'ops@xtablo.com'
|
||||
set -x ADMIN_OPERATOR_ID 'operator-1'
|
||||
set -x ADMIN_OPERATOR_ROLE 'superadmin'
|
||||
|
||||
node -e 'const {createHmac}=require("crypto"); const enc=(v)=>Buffer.from(JSON.stringify(v)).toString("base64url"); const now=Math.floor(Date.now()/1000); const claims={aud:process.env.ADMIN_TOKEN_AUDIENCE||"xtablo-admin",email:process.env.ADMIN_OPERATOR_EMAIL,exp:now+60*15,role:process.env.ADMIN_OPERATOR_ROLE||"superadmin",sub:process.env.ADMIN_OPERATOR_ID,type:"admin_access"}; const header=enc({alg:"HS256",typ:"JWT"}); const payload=enc(claims); const sig=createHmac("sha256",process.env.ADMIN_TOKEN_SIGNING_SECRET).update(`${header}.${payload}`).digest("base64url"); console.log(`${header}.${payload}.${sig}`);'
|
||||
7
admin-token-command.txt
Normal file
7
admin-token-command.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
set ADMIN_TOKEN_SIGNING_SECRET 'Y/NRcCZFluKtSjKgxixDHgeFF0h24B8EdWYLjeviLRYJnnpDpJaphIuQBqfSx3I0NQDC5PqeZ8Z7aJDQAlMp9Q=='
|
||||
set ADMIN_TOKEN_AUDIENCE 'xtablo-admin'
|
||||
set ADMIN_OPERATOR_EMAIL 'ops@xtablo.com'
|
||||
set ADMIN_OPERATOR_ID 'operator-1'
|
||||
set ADMIN_OPERATOR_ROLE 'superadmin'
|
||||
|
||||
node -e 'const {createHmac}=require("crypto"); const enc=(v)=>Buffer.from(JSON.stringify(v)).toString("base64url"); const now=Math.floor(Date.now()/1000); const claims={aud:process.env.ADMIN_TOKEN_AUDIENCE||"xtablo-admin",email:process.env.ADMIN_OPERATOR_EMAIL,exp:now+60*15,role:process.env.ADMIN_OPERATOR_ROLE||"superadmin",sub:process.env.ADMIN_OPERATOR_ID,type:"admin_access"}; const header=enc({alg:"HS256",typ:"JWT"}); const payload=enc(claims); const sig=createHmac("sha256",process.env.ADMIN_TOKEN_SIGNING_SECRET).update(`${header}.${payload}`).digest("base64url"); console.log(`${header}.${payload}.${sig}`);'
|
||||
7
admin-token-from-gcp.fish
Normal file
7
admin-token-from-gcp.fish
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
set -x ADMIN_TOKEN_SIGNING_SECRET (gcloud secrets versions access latest --secret=admin-token-signing-secret --project=xtablo | string collect)
|
||||
set -x ADMIN_TOKEN_AUDIENCE 'xtablo-admin'
|
||||
set -x ADMIN_OPERATOR_EMAIL 'ops@xtablo.com'
|
||||
set -x ADMIN_OPERATOR_ID 'operator-1'
|
||||
set -x ADMIN_OPERATOR_ROLE 'superadmin'
|
||||
|
||||
node -e 'const {createHmac}=require("crypto"); const enc=(v)=>Buffer.from(JSON.stringify(v)).toString("base64url"); const now=Math.floor(Date.now()/1000); const claims={aud:process.env.ADMIN_TOKEN_AUDIENCE||"xtablo-admin",email:process.env.ADMIN_OPERATOR_EMAIL,exp:now+60*15,role:process.env.ADMIN_OPERATOR_ROLE||"superadmin",sub:process.env.ADMIN_OPERATOR_ID,type:"admin_access"}; const header=enc({alg:"HS256",typ:"JWT"}); const payload=enc(claims); const sig=createHmac("sha256",process.env.ADMIN_TOKEN_SIGNING_SECRET).update(`${header}.${payload}`).digest("base64url"); console.log(`${header}.${payload}.${sig}`);'
|
||||
4
admin-token-verify.fish
Normal file
4
admin-token-verify.fish
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
set -x TOKEN 'PASTE_TOKEN_HERE'
|
||||
set -x SECRET 'PASTE_PROD_SECRET_HERE'
|
||||
|
||||
node -e 'const {createHmac,timingSafeEqual}=require("crypto"); const token=process.env.TOKEN; const secret=process.env.SECRET; const [h,p,s]=token.split("."); const expected=createHmac("sha256", secret).update(`${h}.${p}`).digest(); const got=Buffer.from(s,"base64url"); console.log(expected.length===got.length && timingSafeEqual(expected,got) ? "signature-ok" : "signature-mismatch");'
|
||||
|
|
@ -1,80 +1,224 @@
|
|||
import type {
|
||||
AdminDatasetFilters,
|
||||
AdminDatasetPoint,
|
||||
AdminDatasetResult,
|
||||
AdminDatasetSeries,
|
||||
AdminDatasetSummary,
|
||||
} from "@xtablo/shared-types";
|
||||
|
||||
type GrowthFiltersPanelProps = {
|
||||
filters: AdminDatasetFilters;
|
||||
onApply: () => void;
|
||||
onChange: (nextFilters: AdminDatasetFilters) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
type ChartBuilderProps = {
|
||||
dataset: AdminDatasetResult | null;
|
||||
datasets: AdminDatasetSummary[];
|
||||
growthFilters: AdminDatasetFilters | null;
|
||||
onApplyGrowthFilters: () => void;
|
||||
onChangeGrowthFilters: (nextFilters: AdminDatasetFilters) => void;
|
||||
onResetGrowthFilters: () => void;
|
||||
onSelectDatasetId: (datasetId: string) => void;
|
||||
selectedDatasetId: string | null;
|
||||
};
|
||||
|
||||
function BarChart({ points }: { points: AdminDatasetPoint[] }) {
|
||||
const maxValue = Math.max(...points.map((point) => point.value), 1);
|
||||
const PLAN_OPTIONS = [
|
||||
{ id: "solo", label: "Solo" },
|
||||
{ id: "team", label: "Team" },
|
||||
{ id: "annual", label: "Annual" },
|
||||
] as const;
|
||||
|
||||
const ONBOARDING_OPTIONS = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "client", label: "Client" },
|
||||
{ id: "temporary", label: "Temporary" },
|
||||
{ id: "regular", label: "Regular" },
|
||||
] as const;
|
||||
|
||||
function isGrowthDataset(dataset: AdminDatasetResult | null) {
|
||||
return dataset?.id === "profile_growth";
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(`${value}T00:00:00.000Z`));
|
||||
}
|
||||
|
||||
function buildGrowthFilterBadges(filters: AdminDatasetFilters) {
|
||||
const badges = [
|
||||
filters.dateFrom || filters.dateTo
|
||||
? `Range: ${formatDateLabel(filters.dateFrom) ?? "Start"} to ${formatDateLabel(filters.dateTo) ?? "Now"}`
|
||||
: "Range: All time",
|
||||
`Grouping: ${filters.groupBy === "plan" ? "Plan" : "All users"}`,
|
||||
`Access: ${
|
||||
filters.accessStatus === "all"
|
||||
? "Any"
|
||||
: filters.accessStatus === "none"
|
||||
? "No access"
|
||||
: filters.accessStatus
|
||||
}`,
|
||||
];
|
||||
|
||||
if (!filters.plans.every((plan) => PLAN_OPTIONS.some((option) => option.id === plan))) {
|
||||
badges.push(`Plans: ${filters.plans.join(", ")}`);
|
||||
} else if (filters.plans.length === PLAN_OPTIONS.length) {
|
||||
badges.push("Plans: All");
|
||||
} else {
|
||||
badges.push(
|
||||
`Plans: ${PLAN_OPTIONS.filter((option) => filters.plans.includes(option.id))
|
||||
.map((option) => option.label)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.onboardingStates.includes("all")) {
|
||||
badges.push("Profiles: All");
|
||||
} else {
|
||||
badges.push(
|
||||
`Profiles: ${ONBOARDING_OPTIONS.filter((option) =>
|
||||
filters.onboardingStates.includes(option.id)
|
||||
)
|
||||
.map((option) => option.label)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
function EmptyChartState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="grid min-h-64 grid-cols-[repeat(auto-fit,minmax(56px,1fr))] items-end gap-3">
|
||||
{points.map((point) => (
|
||||
<div className="flex h-full flex-col justify-end gap-2" key={point.label}>
|
||||
<div
|
||||
className="rounded-t-2xl bg-[linear-gradient(180deg,rgba(20,36,84,0.92),rgba(88,140,126,0.9))]"
|
||||
style={{ height: `${Math.max((point.value / maxValue) * 180, 12)}px` }}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium">{point.value}</p>
|
||||
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-foreground/55">
|
||||
{point.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-[1.75rem] border border-dashed border-border bg-background/65 px-6 py-12 text-center">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-foreground/45">No Data</p>
|
||||
<p className="mt-3 text-sm text-foreground/70">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineChart({ points }: { points: AdminDatasetPoint[] }) {
|
||||
const width = 560;
|
||||
const height = 220;
|
||||
const maxValue = Math.max(...points.map((point) => point.value), 1);
|
||||
const polyline = points
|
||||
.map((point, index) => {
|
||||
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
|
||||
const y = height - (point.value / maxValue) * (height - 24) - 12;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
function MultiSeriesLineChart({
|
||||
points,
|
||||
series = [],
|
||||
}: {
|
||||
points: AdminDatasetPoint[];
|
||||
series?: AdminDatasetSeries[];
|
||||
}) {
|
||||
const width = 760;
|
||||
const height = 320;
|
||||
const chartSeries = series.length > 0 ? series : [{ id: "total", label: "Total", points }];
|
||||
const labels = Array.from(
|
||||
new Set(chartSeries.flatMap((entry) => entry.points.map((point) => point.label)))
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
|
||||
if (labels.length === 0) {
|
||||
return (
|
||||
<EmptyChartState message="No users match the current filters. Adjust plan, onboarding, access, or date range filters to widen the view." />
|
||||
);
|
||||
}
|
||||
|
||||
const maxValue = Math.max(
|
||||
...chartSeries.flatMap((entry) => entry.points.map((point) => point.value)),
|
||||
1
|
||||
);
|
||||
|
||||
const xForLabel = (label: string) => {
|
||||
const index = labels.indexOf(label);
|
||||
return labels.length <= 1 ? width / 2 : (index / (labels.length - 1)) * (width - 56) + 28;
|
||||
};
|
||||
|
||||
const yForValue = (value: number) => height - (value / maxValue) * (height - 64) - 28;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<svg
|
||||
className="w-full overflow-visible rounded-[2rem] border border-border bg-[linear-gradient(180deg,rgba(252,249,244,0.96),rgba(239,235,225,0.96))] p-4"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={polyline}
|
||||
stroke="rgb(23 37 84)"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
{points.map((point, index) => {
|
||||
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
|
||||
const y = height - (point.value / maxValue) * (height - 24) - 12;
|
||||
<div className="space-y-5">
|
||||
<div className="overflow-hidden rounded-[2rem] border border-border bg-[linear-gradient(180deg,rgba(255,250,240,0.96),rgba(243,248,244,0.92))] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.5)]">
|
||||
<svg className="w-full overflow-visible" viewBox={`0 0 ${width} ${height}`}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = yForValue(maxValue * ratio);
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
stroke="rgba(100,116,139,0.18)"
|
||||
strokeDasharray="6 8"
|
||||
x1="28"
|
||||
x2={width - 28}
|
||||
y1={y}
|
||||
y2={y}
|
||||
/>
|
||||
<text fill="rgba(71,85,105,0.9)" fontSize="12" x="0" y={y + 4}>
|
||||
{Math.round(maxValue * ratio)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
return <circle cx={x} cy={y} fill="rgb(15 118 110)" key={point.label} r="5" />;
|
||||
})}
|
||||
</svg>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{points.map((point) => (
|
||||
{labels.map((label) => (
|
||||
<text
|
||||
fill="rgba(71,85,105,0.88)"
|
||||
fontSize="12"
|
||||
key={label}
|
||||
textAnchor="middle"
|
||||
x={xForLabel(label)}
|
||||
y={height - 4}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{chartSeries.map((entry) => {
|
||||
const polyline = labels
|
||||
.map((label) => {
|
||||
const point = entry.points.find((candidate) => candidate.label === label);
|
||||
return `${xForLabel(label)},${yForValue(point?.value ?? 0)}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<g key={entry.id}>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={polyline}
|
||||
stroke={entry.color ?? "#1736a3"}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
{labels.map((label) => {
|
||||
const point = entry.points.find((candidate) => candidate.label === label);
|
||||
return (
|
||||
<circle
|
||||
cx={xForLabel(label)}
|
||||
cy={yForValue(point?.value ?? 0)}
|
||||
fill={entry.color ?? "#1736a3"}
|
||||
key={`${entry.id}-${label}`}
|
||||
r="4.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{chartSeries.map((entry) => (
|
||||
<div
|
||||
className="rounded-2xl border border-border/80 bg-background/70 px-3 py-2"
|
||||
key={point.label}
|
||||
className="flex items-center gap-3 rounded-full border border-border/80 bg-background/80 px-4 py-2"
|
||||
key={entry.id}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">{point.label}</p>
|
||||
<p className="mt-1 text-lg font-semibold">{point.value}</p>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: entry.color ?? "#1736a3" }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -84,7 +228,7 @@ function LineChart({ points }: { points: AdminDatasetPoint[] }) {
|
|||
|
||||
function DonutChart({ points }: { points: AdminDatasetPoint[] }) {
|
||||
const total = points.reduce((sum, point) => sum + point.value, 0) || 1;
|
||||
const palette = ["#172554", "#0f766e", "#b45309", "#7c2d12", "#475569"];
|
||||
const palette = ["#1736a3", "#0f766e", "#c06a12", "#7c3aed", "#be123c"];
|
||||
let currentStop = 0;
|
||||
const gradientStops = points
|
||||
.map((point, index) => {
|
||||
|
|
@ -98,9 +242,7 @@ function DonutChart({ points }: { points: AdminDatasetPoint[] }) {
|
|||
<div className="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)] md:items-center">
|
||||
<div
|
||||
className="mx-auto h-52 w-52 rounded-full border border-border"
|
||||
style={{
|
||||
background: `conic-gradient(${gradientStops})`,
|
||||
}}
|
||||
style={{ background: `conic-gradient(${gradientStops})` }}
|
||||
>
|
||||
<div className="m-auto mt-8 flex h-36 w-36 items-center justify-center rounded-full bg-card text-center">
|
||||
<div>
|
||||
|
|
@ -130,12 +272,209 @@ function DonutChart({ points }: { points: AdminDatasetPoint[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function BarChart({ points }: { points: AdminDatasetPoint[] }) {
|
||||
const maxValue = Math.max(...points.map((point) => point.value), 1);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-64 grid-cols-[repeat(auto-fit,minmax(72px,1fr))] items-end gap-3">
|
||||
{points.map((point) => (
|
||||
<div className="flex h-full flex-col justify-end gap-2" key={point.label}>
|
||||
<div
|
||||
className="rounded-t-2xl bg-[linear-gradient(180deg,rgba(23,54,163,0.95),rgba(15,118,110,0.9))]"
|
||||
style={{ height: `${Math.max((point.value / maxValue) * 220, 12)}px` }}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium">{point.value}</p>
|
||||
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-foreground/55">
|
||||
{point.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowthFiltersPanel({ filters, onApply, onChange, onReset }: GrowthFiltersPanelProps) {
|
||||
const togglePlan = (planId: string) => {
|
||||
const nextPlans = filters.plans.includes(planId)
|
||||
? filters.plans.filter((entry) => entry !== planId)
|
||||
: [...filters.plans, planId];
|
||||
|
||||
onChange({
|
||||
...filters,
|
||||
plans: nextPlans.length > 0 ? nextPlans : filters.plans,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOnboarding = (state: string) => {
|
||||
const isAll = state === "all";
|
||||
const nextStates: AdminDatasetFilters["onboardingStates"] = isAll
|
||||
? ["all"]
|
||||
: filters.onboardingStates.includes(state as (typeof filters.onboardingStates)[number])
|
||||
? filters.onboardingStates.filter(
|
||||
(entry): entry is AdminDatasetFilters["onboardingStates"][number] =>
|
||||
entry !== state && entry !== "all"
|
||||
)
|
||||
: [
|
||||
...filters.onboardingStates.filter(
|
||||
(entry): entry is AdminDatasetFilters["onboardingStates"][number] => entry !== "all"
|
||||
),
|
||||
state as AdminDatasetFilters["onboardingStates"][number],
|
||||
];
|
||||
|
||||
onChange({
|
||||
...filters,
|
||||
onboardingStates: nextStates.length > 0 ? nextStates : ["all"],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-[2rem] border border-border bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(244,239,229,0.96))] p-5 shadow-[0_18px_60px_rgba(15,23,42,0.06)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Filters</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">Growth Query Controls</h3>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="rounded-full border border-border px-4 py-2 text-sm"
|
||||
onClick={onReset}
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full bg-foreground px-4 py-2 text-sm text-background"
|
||||
onClick={onApply}
|
||||
type="button"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-4">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">Date From</span>
|
||||
<input
|
||||
aria-label="Date From"
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2"
|
||||
onChange={(event) => onChange({ ...filters, dateFrom: event.target.value || null })}
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">Date To</span>
|
||||
<input
|
||||
aria-label="Date To"
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2"
|
||||
onChange={(event) => onChange({ ...filters, dateTo: event.target.value || null })}
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">Group By</span>
|
||||
<select
|
||||
aria-label="Group By"
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...filters,
|
||||
groupBy: event.target.value === "none" ? "none" : "plan",
|
||||
})
|
||||
}
|
||||
value={filters.groupBy}
|
||||
>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">Access</span>
|
||||
<select
|
||||
aria-label="Access"
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...filters,
|
||||
accessStatus: event.target.value as AdminDatasetFilters["accessStatus"],
|
||||
})
|
||||
}
|
||||
value={filters.accessStatus}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="none">No Access</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Plans</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
{PLAN_OPTIONS.map((plan) => (
|
||||
<label
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border bg-background px-3 py-2 text-sm"
|
||||
key={plan.id}
|
||||
>
|
||||
<input
|
||||
aria-label={plan.label}
|
||||
checked={filters.plans.includes(plan.id)}
|
||||
onChange={() => togglePlan(plan.id)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{plan.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Onboarding State</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
{ONBOARDING_OPTIONS.map((option) => (
|
||||
<label
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border bg-background px-3 py-2 text-sm"
|
||||
key={option.id}
|
||||
>
|
||||
<input
|
||||
aria-label={option.label}
|
||||
checked={filters.onboardingStates.includes(option.id)}
|
||||
onChange={() => toggleOnboarding(option.id)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartBuilder({
|
||||
dataset,
|
||||
datasets,
|
||||
growthFilters,
|
||||
onApplyGrowthFilters,
|
||||
onChangeGrowthFilters,
|
||||
onResetGrowthFilters,
|
||||
onSelectDatasetId,
|
||||
selectedDatasetId,
|
||||
}: ChartBuilderProps) {
|
||||
const total = dataset?.points.reduce((sum, point) => sum + point.value, 0) ?? 0;
|
||||
const growthFilterBadges =
|
||||
isGrowthDataset(dataset) && growthFilters ? buildGrowthFilterBadges(growthFilters) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
|
@ -155,6 +494,15 @@ export function ChartBuilder({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{isGrowthDataset(dataset) && growthFilters ? (
|
||||
<GrowthFiltersPanel
|
||||
filters={growthFilters}
|
||||
onApply={onApplyGrowthFilters}
|
||||
onChange={onChangeGrowthFilters}
|
||||
onReset={onResetGrowthFilters}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dataset ? (
|
||||
<section className="rounded-[2rem] border border-border bg-card p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
|
|
@ -162,18 +510,52 @@ export function ChartBuilder({
|
|||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Dataset</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{dataset.label}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm text-foreground/70">{dataset.description}</p>
|
||||
{growthFilterBadges.length ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{growthFilterBadges.map((badge) => (
|
||||
<span
|
||||
className="rounded-full border border-border/80 bg-background/80 px-3 py-1.5 text-xs font-medium text-foreground/70"
|
||||
key={badge}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/80 bg-background/70 px-4 py-3 text-right">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">
|
||||
{dataset.dimensionLabel} x {dataset.metricLabel}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{dataset.points.reduce((sum, point) => sum + point.value, 0)}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold">{total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dataset.chartType === "line" ? <LineChart points={dataset.points} /> : null}
|
||||
{dataset.summary?.length ? (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{dataset.summary.map((metric) => (
|
||||
<article
|
||||
className={`rounded-[1.6rem] border px-4 py-4 ${
|
||||
metric.tone === "positive"
|
||||
? "border-emerald-200 bg-emerald-50/70"
|
||||
: metric.tone === "warning"
|
||||
? "border-amber-200 bg-amber-50/70"
|
||||
: "border-border bg-background/70"
|
||||
}`}
|
||||
key={metric.id}
|
||||
>
|
||||
<p className="text-[11px] uppercase tracking-[0.18em] text-foreground/55">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{metric.value}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dataset.chartType === "line" ? (
|
||||
<MultiSeriesLineChart points={dataset.points} series={dataset.series} />
|
||||
) : null}
|
||||
{dataset.chartType === "bar" ? <BarChart points={dataset.points} /> : null}
|
||||
{dataset.chartType === "donut" ? <DonutChart points={dataset.points} /> : null}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import type { AdminDatasetResult, AdminDatasetSummary } from "@xtablo/shared-typ
|
|||
import { useEffect, useState } from "react";
|
||||
import { adminApi } from "../lib/api";
|
||||
|
||||
type UseAdminDatasetsOptions = {
|
||||
datasetQueryById?: Partial<Record<string, string>>;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
if (typeof error === "object" && error !== null && "response" in error) {
|
||||
const response = error.response;
|
||||
|
|
@ -21,12 +25,13 @@ function getErrorMessage(error: unknown, fallbackMessage: string) {
|
|||
return fallbackMessage;
|
||||
}
|
||||
|
||||
export function useAdminDatasets() {
|
||||
export function useAdminDatasets(options: UseAdminDatasetsOptions = {}) {
|
||||
const [datasets, setDatasets] = useState<AdminDatasetSummary[]>([]);
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
|
||||
const [dataset, setDataset] = useState<AdminDatasetResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const datasetQueryById = options.datasetQueryById ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
|
@ -70,8 +75,9 @@ export function useAdminDatasets() {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const queryString = datasetQueryById[selectedDatasetId] ?? "";
|
||||
const response = await adminApi.get<AdminDatasetResult>(
|
||||
`/admin/datasets/${selectedDatasetId}`
|
||||
`/admin/datasets/${selectedDatasetId}${queryString}`
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
|
|
@ -96,7 +102,7 @@ export function useAdminDatasets() {
|
|||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [selectedDatasetId]);
|
||||
}, [datasetQueryById, selectedDatasetId]);
|
||||
|
||||
return {
|
||||
dataset,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@ import { resolveAdminApiBaseUrl } from "./api";
|
|||
|
||||
describe("resolveAdminApiBaseUrl", () => {
|
||||
it("pins the deployed admin panel to the production api", () => {
|
||||
expect(resolveAdminApiBaseUrl("production", "https://api-staging.xtablo.com/api/v1")).toBe(
|
||||
"https://api.xtablo.com/api/v1"
|
||||
expect(
|
||||
resolveAdminApiBaseUrl(
|
||||
"production",
|
||||
"https://xablo-api-staging-636270553187.europe-west1.run.app/api/v1"
|
||||
)
|
||||
).toBe("https://xablo-api-636270553187.europe-west1.run.app/api/v1");
|
||||
expect(resolveAdminApiBaseUrl("production")).toBe(
|
||||
"https://xablo-api-636270553187.europe-west1.run.app/api/v1"
|
||||
);
|
||||
expect(resolveAdminApiBaseUrl("production")).toBe("https://api.xtablo.com/api/v1");
|
||||
});
|
||||
|
||||
it("keeps localhost for local development", () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { buildApi } from "@xtablo/shared";
|
|||
import { getStoredAdminSession } from "./adminSession";
|
||||
|
||||
const LOCAL_ADMIN_API_BASE_URL = "http://localhost:8080/api/v1";
|
||||
const PRODUCTION_ADMIN_API_BASE_URL = "https://api.xtablo.com/api/v1";
|
||||
const PRODUCTION_ADMIN_API_BASE_URL = "https://xablo-api-636270553187.europe-west1.run.app/api/v1";
|
||||
|
||||
export function resolveAdminApiBaseUrl(mode = import.meta.env.MODE, _envApiUrl?: string) {
|
||||
if (mode === "development") {
|
||||
|
|
|
|||
|
|
@ -35,12 +35,20 @@ describe("AnalyticsStudioPage", () => {
|
|||
};
|
||||
}
|
||||
|
||||
if (path === "/admin/datasets/profile_growth") {
|
||||
if (path.startsWith("/admin/datasets/profile_growth")) {
|
||||
return {
|
||||
data: {
|
||||
chartType: "line",
|
||||
description: "New users over time.",
|
||||
dimensionLabel: "Created Day",
|
||||
filters: {
|
||||
accessStatus: "all",
|
||||
dateFrom: "2026-04-01",
|
||||
dateTo: "2026-04-30",
|
||||
groupBy: "plan",
|
||||
onboardingStates: ["all"],
|
||||
plans: ["solo", "team", "annual"],
|
||||
},
|
||||
id: "profile_growth",
|
||||
label: "User Growth",
|
||||
metricLabel: "Users Created",
|
||||
|
|
@ -48,6 +56,28 @@ describe("AnalyticsStudioPage", () => {
|
|||
{ label: "2026-04-20", value: 2 },
|
||||
{ label: "2026-04-21", value: 4 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
id: "solo",
|
||||
label: "Solo",
|
||||
points: [
|
||||
{ label: "2026-04-20", value: 1 },
|
||||
{ label: "2026-04-21", value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
label: "Team",
|
||||
points: [
|
||||
{ label: "2026-04-20", value: 1 },
|
||||
{ label: "2026-04-21", value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
summary: [
|
||||
{ id: "total_in_range", label: "Users in Range", value: "4" },
|
||||
{ id: "active_access_users", label: "Active Access Users", value: "3" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -76,10 +106,85 @@ describe("AnalyticsStudioPage", () => {
|
|||
|
||||
expect(await screen.findByText(/analytics studio/i)).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: /user growth/i })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/users in range/i)).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText(/date from/i)).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText(/group by/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /plan mix/i }));
|
||||
|
||||
await waitFor(() => expect(adminApi.get).toHaveBeenCalledWith("/admin/datasets/plan_mix"));
|
||||
expect(await screen.findByText(/total/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies analytics filters to the growth dataset request", async () => {
|
||||
vi.mocked(adminApi.get).mockImplementation(async (path: string) => {
|
||||
if (path === "/admin/datasets") {
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
description: "New users over time.",
|
||||
id: "profile_growth",
|
||||
label: "User Growth",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (path.startsWith("/admin/datasets/profile_growth")) {
|
||||
return {
|
||||
data: {
|
||||
chartType: "line",
|
||||
description: "New users over time.",
|
||||
dimensionLabel: "Created Day",
|
||||
filters: {
|
||||
accessStatus: "all",
|
||||
dateFrom: "2026-04-01",
|
||||
dateTo: "2026-04-30",
|
||||
groupBy: "plan",
|
||||
onboardingStates: ["all"],
|
||||
plans: ["solo", "team", "annual"],
|
||||
},
|
||||
id: "profile_growth",
|
||||
label: "User Growth",
|
||||
metricLabel: "Users Created",
|
||||
points: [
|
||||
{ label: "2026-04-20", value: 2 },
|
||||
{ label: "2026-04-21", value: 4 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
id: "solo",
|
||||
label: "Solo",
|
||||
points: [
|
||||
{ label: "2026-04-20", value: 1 },
|
||||
{ label: "2026-04-21", value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
summary: [{ id: "total_in_range", label: "Users in Range", value: "4" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
});
|
||||
|
||||
render(<AnalyticsStudioPage />);
|
||||
|
||||
expect(await screen.findByLabelText(/date from/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/date from/i), { target: { value: "2026-04-10" } });
|
||||
fireEvent.change(screen.getByLabelText(/date to/i), { target: { value: "2026-04-25" } });
|
||||
fireEvent.change(screen.getByLabelText(/access/i), { target: { value: "active" } });
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /team/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /apply filters/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.get).toHaveBeenCalledWith(
|
||||
"/admin/datasets/profile_growth?dateFrom=2026-04-10&dateTo=2026-04-25&groupBy=plan&plans=solo%2Cannual&onboardingStates=all&accessStatus=active"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,67 @@
|
|||
import type { AdminDatasetFilters } from "@xtablo/shared-types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChartBuilder } from "../components/analytics/ChartBuilder";
|
||||
import { SavedDashboardList } from "../components/analytics/SavedDashboardList";
|
||||
import { useAdminDatasets } from "../hooks/useAdminDatasets";
|
||||
import { savedDashboardPresets } from "../registry/datasets";
|
||||
|
||||
const DEFAULT_GROWTH_FILTERS: AdminDatasetFilters = {
|
||||
accessStatus: "all",
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
groupBy: "plan",
|
||||
onboardingStates: ["all"],
|
||||
plans: ["solo", "team", "annual"],
|
||||
};
|
||||
|
||||
function buildDatasetQuery(filters: AdminDatasetFilters) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.dateFrom) {
|
||||
params.set("dateFrom", filters.dateFrom);
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
params.set("dateTo", filters.dateTo);
|
||||
}
|
||||
|
||||
params.set("groupBy", filters.groupBy);
|
||||
params.set("plans", filters.plans.join(","));
|
||||
params.set("onboardingStates", filters.onboardingStates.join(","));
|
||||
params.set("accessStatus", filters.accessStatus);
|
||||
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
function areFiltersEqual(left: AdminDatasetFilters, right: AdminDatasetFilters) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function AnalyticsStudioPage() {
|
||||
const [draftGrowthFilters, setDraftGrowthFilters] =
|
||||
useState<AdminDatasetFilters>(DEFAULT_GROWTH_FILTERS);
|
||||
const [appliedGrowthFilters, setAppliedGrowthFilters] =
|
||||
useState<AdminDatasetFilters>(DEFAULT_GROWTH_FILTERS);
|
||||
|
||||
const datasetQueryById = useMemo(
|
||||
() => ({
|
||||
profile_growth: buildDatasetQuery(appliedGrowthFilters),
|
||||
}),
|
||||
[appliedGrowthFilters]
|
||||
);
|
||||
|
||||
const { dataset, datasets, error, isLoading, selectedDatasetId, setSelectedDatasetId } =
|
||||
useAdminDatasets();
|
||||
useAdminDatasets({ datasetQueryById });
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
dataset?.id === "profile_growth" &&
|
||||
dataset.filters &&
|
||||
!areFiltersEqual(dataset.filters, appliedGrowthFilters)
|
||||
) {
|
||||
setDraftGrowthFilters(dataset.filters);
|
||||
setAppliedGrowthFilters(dataset.filters);
|
||||
}
|
||||
}, [appliedGrowthFilters, dataset]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -25,6 +81,13 @@ export function AnalyticsStudioPage() {
|
|||
<ChartBuilder
|
||||
dataset={dataset}
|
||||
datasets={datasets}
|
||||
growthFilters={selectedDatasetId === "profile_growth" ? draftGrowthFilters : null}
|
||||
onApplyGrowthFilters={() => setAppliedGrowthFilters(draftGrowthFilters)}
|
||||
onChangeGrowthFilters={setDraftGrowthFilters}
|
||||
onResetGrowthFilters={() => {
|
||||
setDraftGrowthFilters(DEFAULT_GROWTH_FILTERS);
|
||||
setAppliedGrowthFilters(DEFAULT_GROWTH_FILTERS);
|
||||
}}
|
||||
onSelectDatasetId={setSelectedDatasetId}
|
||||
selectedDatasetId={selectedDatasetId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -61,4 +61,34 @@ describe("Admin Datasets Router", () => {
|
|||
points: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a filterable growth dataset with grouped series and summary metrics", async () => {
|
||||
const res = await app.request(
|
||||
"/admin/datasets/profile_growth?dateFrom=2026-01-01&dateTo=2026-12-31&groupBy=plan&plans=solo,team&onboardingStates=client&accessStatus=active",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
chartType: "line",
|
||||
id: "profile_growth",
|
||||
summary: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "total_in_range" }),
|
||||
expect.objectContaining({ id: "active_access_users" }),
|
||||
]),
|
||||
filters: expect.objectContaining({
|
||||
accessStatus: "active",
|
||||
dateFrom: "2026-01-01",
|
||||
dateTo: "2026-12-31",
|
||||
groupBy: "plan",
|
||||
onboardingStates: ["client"],
|
||||
plans: ["solo", "team"],
|
||||
}),
|
||||
series: expect.any(Array),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,32 @@
|
|||
import type { AdminDatasetResult } from "@xtablo/shared-types";
|
||||
import type {
|
||||
AdminDatasetAccessStatus,
|
||||
AdminDatasetFilters,
|
||||
AdminDatasetMetric,
|
||||
AdminDatasetPoint,
|
||||
AdminDatasetResult,
|
||||
AdminDatasetSeries,
|
||||
} from "@xtablo/shared-types";
|
||||
import { Hono } from "hono";
|
||||
import { getAdminDatasetDefinition, listAdminDatasets } from "../helpers/adminRegistry.js";
|
||||
import { normalizePlan } from "../helpers/helpers.js";
|
||||
import type { BaseEnv } from "../types/app.types.js";
|
||||
|
||||
const PLAN_ORDER = ["solo", "team", "annual"] as const;
|
||||
const SERIES_COLORS = ["#1736a3", "#0f766e", "#c06a12", "#7c3aed", "#be123c"];
|
||||
|
||||
type GrowthProfileRow = {
|
||||
created_at: string | null;
|
||||
id: string;
|
||||
is_client: boolean;
|
||||
is_temporary: boolean;
|
||||
plan: string | null;
|
||||
};
|
||||
|
||||
type AccessRow = {
|
||||
is_active: boolean | null;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
function bucketByDay(values: Array<string | null>) {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
|
|
@ -20,6 +44,141 @@ function bucketByDay(values: Array<string | null>) {
|
|||
.map(([label, value]) => ({ label, value }));
|
||||
}
|
||||
|
||||
function buildDateRangeFilters(query: Record<string, string | undefined>) {
|
||||
const dateFrom = query.dateFrom?.trim() || null;
|
||||
const dateTo = query.dateTo?.trim() || null;
|
||||
|
||||
return { dateFrom, dateTo };
|
||||
}
|
||||
|
||||
function parseListFilter<T extends string>(
|
||||
rawValue: string | undefined,
|
||||
allowedValues: readonly T[],
|
||||
defaultValues: readonly T[]
|
||||
) {
|
||||
if (!rawValue) {
|
||||
return [...defaultValues];
|
||||
}
|
||||
|
||||
const parsed = rawValue
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is T => allowedValues.includes(value as T));
|
||||
|
||||
return parsed.length > 0 ? parsed : [...defaultValues];
|
||||
}
|
||||
|
||||
function parseGrowthFilters(query: Record<string, string | undefined>): AdminDatasetFilters {
|
||||
const { dateFrom, dateTo } = buildDateRangeFilters(query);
|
||||
const groupBy = query.groupBy === "none" ? "none" : "plan";
|
||||
const accessStatus = (["all", "active", "inactive", "none"] as const).includes(
|
||||
query.accessStatus as AdminDatasetAccessStatus
|
||||
)
|
||||
? (query.accessStatus as AdminDatasetAccessStatus)
|
||||
: "all";
|
||||
|
||||
return {
|
||||
accessStatus,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
groupBy,
|
||||
onboardingStates: parseListFilter(
|
||||
query.onboardingStates,
|
||||
["all", "client", "temporary", "regular"] as const,
|
||||
["all"] as const
|
||||
),
|
||||
plans: parseListFilter(query.plans, PLAN_ORDER, PLAN_ORDER),
|
||||
};
|
||||
}
|
||||
|
||||
function onboardingStateForProfile(profile: Pick<GrowthProfileRow, "is_client" | "is_temporary">) {
|
||||
if (profile.is_temporary) {
|
||||
return "temporary" as const;
|
||||
}
|
||||
|
||||
if (profile.is_client) {
|
||||
return "client" as const;
|
||||
}
|
||||
|
||||
return "regular" as const;
|
||||
}
|
||||
|
||||
function buildAccessStateByUser(rows: AccessRow[]) {
|
||||
const states = new Map<string, AdminDatasetAccessStatus>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const current = states.get(row.user_id);
|
||||
|
||||
if (row.is_active) {
|
||||
states.set(row.user_id, "active");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
states.set(row.user_id, "inactive");
|
||||
}
|
||||
});
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
function bucketRowsByDay(rows: GrowthProfileRow[]): AdminDatasetPoint[] {
|
||||
return bucketByDay(rows.map((row) => row.created_at));
|
||||
}
|
||||
|
||||
function buildGrowthSeries(rows: GrowthProfileRow[], plans: string[]): AdminDatasetSeries[] {
|
||||
return plans.map((plan, index) => {
|
||||
const points = bucketRowsByDay(rows.filter((row) => normalizePlan(row.plan) === plan));
|
||||
|
||||
return {
|
||||
color: SERIES_COLORS[index % SERIES_COLORS.length],
|
||||
id: plan,
|
||||
label: plan.charAt(0).toUpperCase() + plan.slice(1),
|
||||
points,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatMetricValue(value: number) {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
function buildGrowthSummary(
|
||||
rows: GrowthProfileRow[],
|
||||
accessStates: Map<string, AdminDatasetAccessStatus>
|
||||
): AdminDatasetMetric[] {
|
||||
const activeAccessUsers = rows.filter((row) => accessStates.get(row.id) === "active").length;
|
||||
const clientProfiles = rows.filter((row) => row.is_client).length;
|
||||
const temporaryProfiles = rows.filter((row) => row.is_temporary).length;
|
||||
const teamPlanUsers = rows.filter((row) => normalizePlan(row.plan) === "team").length;
|
||||
|
||||
return [
|
||||
{ id: "total_in_range", label: "Users in Range", value: formatMetricValue(rows.length) },
|
||||
{
|
||||
id: "active_access_users",
|
||||
label: "Active Access Users",
|
||||
tone: "positive",
|
||||
value: formatMetricValue(activeAccessUsers),
|
||||
},
|
||||
{
|
||||
id: "client_profiles",
|
||||
label: "Client Profiles",
|
||||
value: formatMetricValue(clientProfiles),
|
||||
},
|
||||
{
|
||||
id: "team_plan_users",
|
||||
label: "Team Plan Users",
|
||||
tone: "warning",
|
||||
value: formatMetricValue(teamPlanUsers),
|
||||
},
|
||||
{
|
||||
id: "temporary_profiles",
|
||||
label: "Temporary Profiles",
|
||||
value: formatMetricValue(temporaryProfiles),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function bucketByValue(values: Array<string | null>, emptyLabel: string) {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
|
|
@ -55,30 +214,92 @@ function bucketTabloAccess(rows: Array<{ is_active: boolean | null; is_admin: bo
|
|||
|
||||
type AdminDatasetPayload = Pick<
|
||||
AdminDatasetResult,
|
||||
"chartType" | "dimensionLabel" | "metricLabel" | "points"
|
||||
"chartType" | "dimensionLabel" | "filters" | "metricLabel" | "points" | "series" | "summary"
|
||||
>;
|
||||
|
||||
async function getDatasetPoints(
|
||||
datasetId: string,
|
||||
supabase: BaseEnv["Variables"]["supabase"]
|
||||
supabase: BaseEnv["Variables"]["supabase"],
|
||||
query: Record<string, string | undefined>
|
||||
): Promise<AdminDatasetPayload> {
|
||||
switch (datasetId) {
|
||||
case "profile_growth": {
|
||||
const { data, error } = await supabase
|
||||
const filters = parseGrowthFilters(query);
|
||||
let profileQuery = supabase
|
||||
.from("profiles")
|
||||
.select("created_at")
|
||||
.select("id,created_at,plan,is_client,is_temporary")
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(365);
|
||||
.limit(2000);
|
||||
|
||||
if (filters.dateFrom) {
|
||||
profileQuery = profileQuery.gte("created_at", `${filters.dateFrom}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
profileQuery = profileQuery.lte("created_at", `${filters.dateTo}T23:59:59.999Z`);
|
||||
}
|
||||
|
||||
const { data, error } = await profileQuery;
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const { data: accessRows, error: accessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("user_id,is_active")
|
||||
.limit(5000);
|
||||
|
||||
if (accessError) {
|
||||
throw new Error(accessError.message);
|
||||
}
|
||||
|
||||
const accessStates = buildAccessStateByUser((accessRows ?? []) as AccessRow[]);
|
||||
const filteredProfiles = ((data ?? []) as GrowthProfileRow[]).filter((row) => {
|
||||
const normalizedPlan = normalizePlan(row.plan);
|
||||
if (!filters.plans.includes(normalizedPlan)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const onboardingState = onboardingStateForProfile(row);
|
||||
if (
|
||||
!filters.onboardingStates.includes("all") &&
|
||||
!filters.onboardingStates.includes(onboardingState)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accessState = accessStates.get(row.id) ?? "none";
|
||||
if (filters.accessStatus !== "all" && accessState !== filters.accessStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const series =
|
||||
filters.groupBy === "plan"
|
||||
? buildGrowthSeries(filteredProfiles, filters.plans)
|
||||
: [
|
||||
{
|
||||
color: SERIES_COLORS[0],
|
||||
id: "all",
|
||||
label: "All Users",
|
||||
points: bucketRowsByDay(filteredProfiles),
|
||||
},
|
||||
];
|
||||
|
||||
const aggregatePoints =
|
||||
filters.groupBy === "plan" ? bucketRowsByDay(filteredProfiles) : (series[0]?.points ?? []);
|
||||
|
||||
return {
|
||||
chartType: "line",
|
||||
dimensionLabel: "Created Day",
|
||||
filters,
|
||||
metricLabel: "Users Created",
|
||||
points: bucketByDay((data ?? []).map((row) => row.created_at)),
|
||||
points: aggregatePoints,
|
||||
series,
|
||||
summary: buildGrowthSummary(filteredProfiles, accessStates),
|
||||
};
|
||||
}
|
||||
case "plan_mix": {
|
||||
|
|
@ -137,7 +358,7 @@ export const getAdminDatasetsRouter = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
const dataset = await getDatasetPoints(datasetId, supabase);
|
||||
const dataset = await getDatasetPoints(datasetId, supabase, c.req.query());
|
||||
|
||||
return c.json(
|
||||
{
|
||||
|
|
|
|||
2
apps/external/tsconfig.tsbuildinfo
vendored
2
apps/external/tsconfig.tsbuildinfo
vendored
|
|
@ -1 +1 @@
|
|||
{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"}
|
||||
|
|
@ -22,10 +22,10 @@ export const usePendingClientInvites = (tabloId: string) => {
|
|||
return useQuery({
|
||||
queryKey: ["client-invites", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<PendingClientInvite[]>(
|
||||
const { data } = await api.get<{ invites: PendingClientInvite[] }>(
|
||||
`/api/v1/client-invites/${tabloId}/pending`
|
||||
);
|
||||
return data;
|
||||
return data.invites;
|
||||
},
|
||||
enabled: !!tabloId && !!session,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -386,9 +386,9 @@ export const TabloDetailsPage = () => {
|
|||
members={members}
|
||||
etapes={etapes}
|
||||
currentUser={currentUser}
|
||||
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isCreatingClientInvite}
|
||||
isCancellingInvite={isCancellingClientInvite}
|
||||
onCreateTask={(task) => createTask(task)}
|
||||
onUpdateTask={(task) => updateTask(task)}
|
||||
onDeleteTask={(taskId) => deleteTask(taskId)}
|
||||
|
|
@ -396,9 +396,11 @@ export const TabloDetailsPage = () => {
|
|||
onUpdateTablo={(data) =>
|
||||
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
|
||||
}
|
||||
onInviteUser={inviteUser}
|
||||
onInviteUser={(params) =>
|
||||
createClientInvite({ tabloId: params.tablo_id, email: params.email })
|
||||
}
|
||||
onCancelInvite={(params) =>
|
||||
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
|
||||
cancelClientInvite({ tabloId: params.tabloId, inviteId: Number(params.inviteId) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -415,9 +417,9 @@ export const TabloDetailsPage = () => {
|
|||
foldersError={foldersError as Error | null}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isCreatingClientInvite}
|
||||
isCancellingInvite={isCancellingClientInvite}
|
||||
isCreatingFolder={isCreatingFolder}
|
||||
isUpdatingFolder={isUpdatingFolder}
|
||||
onCreateFile={(params) => uploadFile(params).then(() => undefined)}
|
||||
|
|
@ -429,9 +431,11 @@ export const TabloDetailsPage = () => {
|
|||
onUpdateTablo={(data) =>
|
||||
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
|
||||
}
|
||||
onInviteUser={inviteUser}
|
||||
onInviteUser={(params) =>
|
||||
createClientInvite({ tabloId: params.tablo_id, email: params.email })
|
||||
}
|
||||
onCancelInvite={(params) =>
|
||||
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
|
||||
cancelClientInvite({ tabloId: params.tabloId, inviteId: Number(params.inviteId) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -454,16 +458,18 @@ export const TabloDetailsPage = () => {
|
|||
error={eventsError as Error | null}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isCreatingClientInvite}
|
||||
isCancellingInvite={isCancellingClientInvite}
|
||||
onCreateEvent={() => undefined}
|
||||
onUpdateTablo={(data) =>
|
||||
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
|
||||
}
|
||||
onInviteUser={inviteUser}
|
||||
onInviteUser={(params) =>
|
||||
createClientInvite({ tabloId: params.tablo_id, email: params.email })
|
||||
}
|
||||
onCancelInvite={(params) =>
|
||||
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
|
||||
cancelClientInvite({ tabloId: params.tabloId, inviteId: Number(params.inviteId) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ export type AdminOverviewResponse = {
|
|||
|
||||
export type AdminDatasetChartType = "bar" | "line" | "donut";
|
||||
|
||||
export type AdminDatasetGroupBy = "none" | "plan";
|
||||
|
||||
export type AdminDatasetAccessStatus = "all" | "active" | "inactive" | "none";
|
||||
|
||||
export type AdminDatasetOnboardingState = "all" | "client" | "temporary" | "regular";
|
||||
|
||||
export type AdminDatasetSummary = {
|
||||
description: string;
|
||||
id: string;
|
||||
|
|
@ -73,11 +79,37 @@ export type AdminDatasetPoint = {
|
|||
value: number;
|
||||
};
|
||||
|
||||
export type AdminDatasetSeries = {
|
||||
color?: string;
|
||||
id: string;
|
||||
label: string;
|
||||
points: AdminDatasetPoint[];
|
||||
};
|
||||
|
||||
export type AdminDatasetMetric = {
|
||||
id: string;
|
||||
label: string;
|
||||
tone?: "default" | "positive" | "warning";
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AdminDatasetFilters = {
|
||||
accessStatus: AdminDatasetAccessStatus;
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
groupBy: AdminDatasetGroupBy;
|
||||
onboardingStates: AdminDatasetOnboardingState[];
|
||||
plans: string[];
|
||||
};
|
||||
|
||||
export type AdminDatasetResult = AdminDatasetSummary & {
|
||||
chartType: AdminDatasetChartType;
|
||||
dimensionLabel: string;
|
||||
filters?: AdminDatasetFilters;
|
||||
metricLabel: string;
|
||||
points: AdminDatasetPoint[];
|
||||
series?: AdminDatasetSeries[];
|
||||
summary?: AdminDatasetMetric[];
|
||||
};
|
||||
|
||||
export type AdminActionField = {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,16 @@ export type {
|
|||
AdminActionField,
|
||||
AdminActionRunResponse,
|
||||
AdminActionSummary,
|
||||
AdminDatasetAccessStatus,
|
||||
AdminDatasetChartType,
|
||||
AdminDatasetFilters,
|
||||
AdminDatasetGroupBy,
|
||||
AdminDatasetMetric,
|
||||
AdminDatasetPoint,
|
||||
AdminDatasetResult,
|
||||
AdminDatasetSeries,
|
||||
AdminDatasetSummary,
|
||||
AdminDatasetOnboardingState,
|
||||
AdminOverviewAlert,
|
||||
AdminOverviewMetric,
|
||||
AdminOverviewResponse,
|
||||
|
|
|
|||
Loading…
Reference in a new issue