Compare commits

...

1 commit

Author SHA1 Message Date
Arthur Belleville
44fa0ef2a7
Admin panel 2026-05-23 16:39:26 +02:00
17 changed files with 977 additions and 96 deletions

7
admin-token-command.fish Normal file
View 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
View 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}`);'

View 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
View 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");'

View file

@ -1,80 +1,224 @@
import type { import type {
AdminDatasetFilters,
AdminDatasetPoint, AdminDatasetPoint,
AdminDatasetResult, AdminDatasetResult,
AdminDatasetSeries,
AdminDatasetSummary, AdminDatasetSummary,
} from "@xtablo/shared-types"; } from "@xtablo/shared-types";
type GrowthFiltersPanelProps = {
filters: AdminDatasetFilters;
onApply: () => void;
onChange: (nextFilters: AdminDatasetFilters) => void;
onReset: () => void;
};
type ChartBuilderProps = { type ChartBuilderProps = {
dataset: AdminDatasetResult | null; dataset: AdminDatasetResult | null;
datasets: AdminDatasetSummary[]; datasets: AdminDatasetSummary[];
growthFilters: AdminDatasetFilters | null;
onApplyGrowthFilters: () => void;
onChangeGrowthFilters: (nextFilters: AdminDatasetFilters) => void;
onResetGrowthFilters: () => void;
onSelectDatasetId: (datasetId: string) => void; onSelectDatasetId: (datasetId: string) => void;
selectedDatasetId: string | null; selectedDatasetId: string | null;
}; };
function BarChart({ points }: { points: AdminDatasetPoint[] }) { const PLAN_OPTIONS = [
const maxValue = Math.max(...points.map((point) => point.value), 1); { 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 ( return (
<div className="grid min-h-64 grid-cols-[repeat(auto-fit,minmax(56px,1fr))] items-end gap-3"> <div className="rounded-[1.75rem] border border-dashed border-border bg-background/65 px-6 py-12 text-center">
{points.map((point) => ( <p className="text-xs uppercase tracking-[0.18em] text-foreground/45">No Data</p>
<div className="flex h-full flex-col justify-end gap-2" key={point.label}> <p className="mt-3 text-sm text-foreground/70">{message}</p>
<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> </div>
); );
} }
function LineChart({ points }: { points: AdminDatasetPoint[] }) { function MultiSeriesLineChart({
const width = 560; points,
const height = 220; series = [],
const maxValue = Math.max(...points.map((point) => point.value), 1); }: {
const polyline = points points: AdminDatasetPoint[];
.map((point, index) => { series?: AdminDatasetSeries[];
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width; }) {
const y = height - (point.value / maxValue) * (height - 24) - 12; const width = 760;
return `${x},${y}`; 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-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>
);
})}
{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(" "); .join(" ");
return ( return (
<div className="space-y-4"> <g key={entry.id}>
<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 <polyline
fill="none" fill="none"
points={polyline} points={polyline}
stroke="rgb(23 37 84)" stroke={entry.color ?? "#1736a3"}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="4" strokeWidth="4"
/> />
{points.map((point, index) => { {labels.map((label) => {
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width; const point = entry.points.find((candidate) => candidate.label === label);
const y = height - (point.value / maxValue) * (height - 24) - 12; return (
<circle
return <circle cx={x} cy={y} fill="rgb(15 118 110)" key={point.label} r="5" />; cx={xForLabel(label)}
cy={yForValue(point?.value ?? 0)}
fill={entry.color ?? "#1736a3"}
key={`${entry.id}-${label}`}
r="4.5"
/>
);
})}
</g>
);
})} })}
</svg> </svg>
<div className="grid gap-3 md:grid-cols-3"> </div>
{points.map((point) => (
<div className="flex flex-wrap gap-3">
{chartSeries.map((entry) => (
<div <div
className="rounded-2xl border border-border/80 bg-background/70 px-3 py-2" className="flex items-center gap-3 rounded-full border border-border/80 bg-background/80 px-4 py-2"
key={point.label} key={entry.id}
> >
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">{point.label}</p> <span
<p className="mt-1 text-lg font-semibold">{point.value}</p> className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color ?? "#1736a3" }}
/>
<span className="text-sm font-medium">{entry.label}</span>
</div> </div>
))} ))}
</div> </div>
@ -84,7 +228,7 @@ function LineChart({ points }: { points: AdminDatasetPoint[] }) {
function DonutChart({ points }: { points: AdminDatasetPoint[] }) { function DonutChart({ points }: { points: AdminDatasetPoint[] }) {
const total = points.reduce((sum, point) => sum + point.value, 0) || 1; 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; let currentStop = 0;
const gradientStops = points const gradientStops = points
.map((point, index) => { .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="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)] md:items-center">
<div <div
className="mx-auto h-52 w-52 rounded-full border border-border" className="mx-auto h-52 w-52 rounded-full border border-border"
style={{ style={{ background: `conic-gradient(${gradientStops})` }}
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 className="m-auto mt-8 flex h-36 w-36 items-center justify-center rounded-full bg-card text-center">
<div> <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({ export function ChartBuilder({
dataset, dataset,
datasets, datasets,
growthFilters,
onApplyGrowthFilters,
onChangeGrowthFilters,
onResetGrowthFilters,
onSelectDatasetId, onSelectDatasetId,
selectedDatasetId, selectedDatasetId,
}: ChartBuilderProps) { }: ChartBuilderProps) {
const total = dataset?.points.reduce((sum, point) => sum + point.value, 0) ?? 0;
const growthFilterBadges =
isGrowthDataset(dataset) && growthFilters ? buildGrowthFilterBadges(growthFilters) : [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@ -155,6 +494,15 @@ export function ChartBuilder({
))} ))}
</div> </div>
{isGrowthDataset(dataset) && growthFilters ? (
<GrowthFiltersPanel
filters={growthFilters}
onApply={onApplyGrowthFilters}
onChange={onChangeGrowthFilters}
onReset={onResetGrowthFilters}
/>
) : null}
{dataset ? ( {dataset ? (
<section className="rounded-[2rem] border border-border bg-card p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)]"> <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"> <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> <p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Dataset</p>
<h2 className="mt-2 text-3xl font-semibold">{dataset.label}</h2> <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> <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>
<div className="rounded-2xl border border-border/80 bg-background/70 px-4 py-3 text-right"> <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"> <p className="text-xs uppercase tracking-[0.18em] text-foreground/50">
{dataset.dimensionLabel} x {dataset.metricLabel} {dataset.dimensionLabel} x {dataset.metricLabel}
</p> </p>
<p className="mt-2 text-lg font-semibold"> <p className="mt-2 text-lg font-semibold">{total}</p>
{dataset.points.reduce((sum, point) => sum + point.value, 0)}
</p>
</div> </div>
</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 === "bar" ? <BarChart points={dataset.points} /> : null}
{dataset.chartType === "donut" ? <DonutChart points={dataset.points} /> : null} {dataset.chartType === "donut" ? <DonutChart points={dataset.points} /> : null}
</section> </section>

View file

@ -2,6 +2,10 @@ import type { AdminDatasetResult, AdminDatasetSummary } from "@xtablo/shared-typ
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { adminApi } from "../lib/api"; import { adminApi } from "../lib/api";
type UseAdminDatasetsOptions = {
datasetQueryById?: Partial<Record<string, string>>;
};
function getErrorMessage(error: unknown, fallbackMessage: string) { function getErrorMessage(error: unknown, fallbackMessage: string) {
if (typeof error === "object" && error !== null && "response" in error) { if (typeof error === "object" && error !== null && "response" in error) {
const response = error.response; const response = error.response;
@ -21,12 +25,13 @@ function getErrorMessage(error: unknown, fallbackMessage: string) {
return fallbackMessage; return fallbackMessage;
} }
export function useAdminDatasets() { export function useAdminDatasets(options: UseAdminDatasetsOptions = {}) {
const [datasets, setDatasets] = useState<AdminDatasetSummary[]>([]); const [datasets, setDatasets] = useState<AdminDatasetSummary[]>([]);
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null); const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
const [dataset, setDataset] = useState<AdminDatasetResult | null>(null); const [dataset, setDataset] = useState<AdminDatasetResult | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const datasetQueryById = options.datasetQueryById ?? {};
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@ -70,8 +75,9 @@ export function useAdminDatasets() {
setError(null); setError(null);
try { try {
const queryString = datasetQueryById[selectedDatasetId] ?? "";
const response = await adminApi.get<AdminDatasetResult>( const response = await adminApi.get<AdminDatasetResult>(
`/admin/datasets/${selectedDatasetId}` `/admin/datasets/${selectedDatasetId}${queryString}`
); );
if (!isMounted) { if (!isMounted) {
@ -96,7 +102,7 @@ export function useAdminDatasets() {
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [selectedDatasetId]); }, [datasetQueryById, selectedDatasetId]);
return { return {
dataset, dataset,

View file

@ -3,10 +3,15 @@ import { resolveAdminApiBaseUrl } from "./api";
describe("resolveAdminApiBaseUrl", () => { describe("resolveAdminApiBaseUrl", () => {
it("pins the deployed admin panel to the production api", () => { it("pins the deployed admin panel to the production api", () => {
expect(resolveAdminApiBaseUrl("production", "https://api-staging.xtablo.com/api/v1")).toBe( expect(
"https://api.xtablo.com/api/v1" 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", () => { it("keeps localhost for local development", () => {

View file

@ -2,7 +2,7 @@ import { buildApi } from "@xtablo/shared";
import { getStoredAdminSession } from "./adminSession"; import { getStoredAdminSession } from "./adminSession";
const LOCAL_ADMIN_API_BASE_URL = "http://localhost:8080/api/v1"; 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) { export function resolveAdminApiBaseUrl(mode = import.meta.env.MODE, _envApiUrl?: string) {
if (mode === "development") { if (mode === "development") {

View file

@ -35,12 +35,20 @@ describe("AnalyticsStudioPage", () => {
}; };
} }
if (path === "/admin/datasets/profile_growth") { if (path.startsWith("/admin/datasets/profile_growth")) {
return { return {
data: { data: {
chartType: "line", chartType: "line",
description: "New users over time.", description: "New users over time.",
dimensionLabel: "Created Day", 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", id: "profile_growth",
label: "User Growth", label: "User Growth",
metricLabel: "Users Created", metricLabel: "Users Created",
@ -48,6 +56,28 @@ describe("AnalyticsStudioPage", () => {
{ label: "2026-04-20", value: 2 }, { label: "2026-04-20", value: 2 },
{ label: "2026-04-21", value: 4 }, { 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.findByText(/analytics studio/i)).toBeInTheDocument();
expect(await screen.findByRole("button", { name: /user growth/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 })); fireEvent.click(screen.getByRole("button", { name: /plan mix/i }));
await waitFor(() => expect(adminApi.get).toHaveBeenCalledWith("/admin/datasets/plan_mix")); await waitFor(() => expect(adminApi.get).toHaveBeenCalledWith("/admin/datasets/plan_mix"));
expect(await screen.findByText(/total/i)).toBeInTheDocument(); 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"
)
);
});
}); });

View file

@ -1,11 +1,67 @@
import type { AdminDatasetFilters } from "@xtablo/shared-types";
import { useEffect, useMemo, useState } from "react";
import { ChartBuilder } from "../components/analytics/ChartBuilder"; import { ChartBuilder } from "../components/analytics/ChartBuilder";
import { SavedDashboardList } from "../components/analytics/SavedDashboardList"; import { SavedDashboardList } from "../components/analytics/SavedDashboardList";
import { useAdminDatasets } from "../hooks/useAdminDatasets"; import { useAdminDatasets } from "../hooks/useAdminDatasets";
import { savedDashboardPresets } from "../registry/datasets"; 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() { 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 } = 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -25,6 +81,13 @@ export function AnalyticsStudioPage() {
<ChartBuilder <ChartBuilder
dataset={dataset} dataset={dataset}
datasets={datasets} datasets={datasets}
growthFilters={selectedDatasetId === "profile_growth" ? draftGrowthFilters : null}
onApplyGrowthFilters={() => setAppliedGrowthFilters(draftGrowthFilters)}
onChangeGrowthFilters={setDraftGrowthFilters}
onResetGrowthFilters={() => {
setDraftGrowthFilters(DEFAULT_GROWTH_FILTERS);
setAppliedGrowthFilters(DEFAULT_GROWTH_FILTERS);
}}
onSelectDatasetId={setSelectedDatasetId} onSelectDatasetId={setSelectedDatasetId}
selectedDatasetId={selectedDatasetId} selectedDatasetId={selectedDatasetId}
/> />

View file

@ -61,4 +61,34 @@ describe("Admin Datasets Router", () => {
points: expect.any(Array), 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),
});
});
}); });

View file

@ -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 { Hono } from "hono";
import { getAdminDatasetDefinition, listAdminDatasets } from "../helpers/adminRegistry.js"; import { getAdminDatasetDefinition, listAdminDatasets } from "../helpers/adminRegistry.js";
import { normalizePlan } from "../helpers/helpers.js";
import type { BaseEnv } from "../types/app.types.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>) { function bucketByDay(values: Array<string | null>) {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
@ -20,6 +44,141 @@ function bucketByDay(values: Array<string | null>) {
.map(([label, value]) => ({ label, value })); .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) { function bucketByValue(values: Array<string | null>, emptyLabel: string) {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
@ -55,30 +214,92 @@ function bucketTabloAccess(rows: Array<{ is_active: boolean | null; is_admin: bo
type AdminDatasetPayload = Pick< type AdminDatasetPayload = Pick<
AdminDatasetResult, AdminDatasetResult,
"chartType" | "dimensionLabel" | "metricLabel" | "points" "chartType" | "dimensionLabel" | "filters" | "metricLabel" | "points" | "series" | "summary"
>; >;
async function getDatasetPoints( async function getDatasetPoints(
datasetId: string, datasetId: string,
supabase: BaseEnv["Variables"]["supabase"] supabase: BaseEnv["Variables"]["supabase"],
query: Record<string, string | undefined>
): Promise<AdminDatasetPayload> { ): Promise<AdminDatasetPayload> {
switch (datasetId) { switch (datasetId) {
case "profile_growth": { case "profile_growth": {
const { data, error } = await supabase const filters = parseGrowthFilters(query);
let profileQuery = supabase
.from("profiles") .from("profiles")
.select("created_at") .select("id,created_at,plan,is_client,is_temporary")
.order("created_at", { ascending: true }) .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) { if (error) {
throw new Error(error.message); 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 { return {
chartType: "line", chartType: "line",
dimensionLabel: "Created Day", dimensionLabel: "Created Day",
filters,
metricLabel: "Users Created", metricLabel: "Users Created",
points: bucketByDay((data ?? []).map((row) => row.created_at)), points: aggregatePoints,
series,
summary: buildGrowthSummary(filteredProfiles, accessStates),
}; };
} }
case "plan_mix": { case "plan_mix": {
@ -137,7 +358,7 @@ export const getAdminDatasetsRouter = () => {
} }
try { try {
const dataset = await getDatasetPoints(datasetId, supabase); const dataset = await getDatasetPoints(datasetId, supabase, c.req.query());
return c.json( return c.json(
{ {

View file

@ -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"}

View file

@ -22,10 +22,10 @@ export const usePendingClientInvites = (tabloId: string) => {
return useQuery({ return useQuery({
queryKey: ["client-invites", tabloId], queryKey: ["client-invites", tabloId],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get<PendingClientInvite[]>( const { data } = await api.get<{ invites: PendingClientInvite[] }>(
`/api/v1/client-invites/${tabloId}/pending` `/api/v1/client-invites/${tabloId}/pending`
); );
return data; return data.invites;
}, },
enabled: !!tabloId && !!session, enabled: !!tabloId && !!session,
}); });

View file

@ -386,9 +386,9 @@ export const TabloDetailsPage = () => {
members={members} members={members}
etapes={etapes} etapes={etapes}
currentUser={currentUser} currentUser={currentUser}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser} isInvitingUser={isCreatingClientInvite}
isCancellingInvite={isCancellingInvite} isCancellingInvite={isCancellingClientInvite}
onCreateTask={(task) => createTask(task)} onCreateTask={(task) => createTask(task)}
onUpdateTask={(task) => updateTask(task)} onUpdateTask={(task) => updateTask(task)}
onDeleteTask={(taskId) => deleteTask(taskId)} onDeleteTask={(taskId) => deleteTask(taskId)}
@ -396,9 +396,11 @@ export const TabloDetailsPage = () => {
onUpdateTablo={(data) => onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
} }
onInviteUser={inviteUser} onInviteUser={(params) =>
createClientInvite({ tabloId: params.tablo_id, email: params.email })
}
onCancelInvite={(params) => 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} foldersError={foldersError as Error | null}
currentUser={currentUser} currentUser={currentUser}
members={members} members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser} isInvitingUser={isCreatingClientInvite}
isCancellingInvite={isCancellingInvite} isCancellingInvite={isCancellingClientInvite}
isCreatingFolder={isCreatingFolder} isCreatingFolder={isCreatingFolder}
isUpdatingFolder={isUpdatingFolder} isUpdatingFolder={isUpdatingFolder}
onCreateFile={(params) => uploadFile(params).then(() => undefined)} onCreateFile={(params) => uploadFile(params).then(() => undefined)}
@ -429,9 +431,11 @@ export const TabloDetailsPage = () => {
onUpdateTablo={(data) => onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
} }
onInviteUser={inviteUser} onInviteUser={(params) =>
createClientInvite({ tabloId: params.tablo_id, email: params.email })
}
onCancelInvite={(params) => 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} error={eventsError as Error | null}
currentUser={currentUser} currentUser={currentUser}
members={members} members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser} isInvitingUser={isCreatingClientInvite}
isCancellingInvite={isCancellingInvite} isCancellingInvite={isCancellingClientInvite}
onCreateEvent={() => undefined} onCreateEvent={() => undefined}
onUpdateTablo={(data) => onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
} }
onInviteUser={inviteUser} onInviteUser={(params) =>
createClientInvite({ tabloId: params.tablo_id, email: params.email })
}
onCancelInvite={(params) => onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) }) cancelClientInvite({ tabloId: params.tabloId, inviteId: Number(params.inviteId) })
} }
/> />
)} )}

View file

@ -62,6 +62,12 @@ export type AdminOverviewResponse = {
export type AdminDatasetChartType = "bar" | "line" | "donut"; 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 = { export type AdminDatasetSummary = {
description: string; description: string;
id: string; id: string;
@ -73,11 +79,37 @@ export type AdminDatasetPoint = {
value: number; 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 & { export type AdminDatasetResult = AdminDatasetSummary & {
chartType: AdminDatasetChartType; chartType: AdminDatasetChartType;
dimensionLabel: string; dimensionLabel: string;
filters?: AdminDatasetFilters;
metricLabel: string; metricLabel: string;
points: AdminDatasetPoint[]; points: AdminDatasetPoint[];
series?: AdminDatasetSeries[];
summary?: AdminDatasetMetric[];
}; };
export type AdminActionField = { export type AdminActionField = {

View file

@ -5,10 +5,16 @@ export type {
AdminActionField, AdminActionField,
AdminActionRunResponse, AdminActionRunResponse,
AdminActionSummary, AdminActionSummary,
AdminDatasetAccessStatus,
AdminDatasetChartType, AdminDatasetChartType,
AdminDatasetFilters,
AdminDatasetGroupBy,
AdminDatasetMetric,
AdminDatasetPoint, AdminDatasetPoint,
AdminDatasetResult, AdminDatasetResult,
AdminDatasetSeries,
AdminDatasetSummary, AdminDatasetSummary,
AdminDatasetOnboardingState,
AdminOverviewAlert, AdminOverviewAlert,
AdminOverviewMetric, AdminOverviewMetric,
AdminOverviewResponse, AdminOverviewResponse,