Compare commits
1 commit
main
...
admin-pane
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 {
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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({
|
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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue