mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Portal home: trim to onboarding + processor flow, secondary Set up buttons (#7111)
Simplifies the processor home page: - Removes everything below the processor flow (processing status strip, recent activity, quick actions, policy summary), leaving just the onboarding hero and processor flow. - Changes the policy "Set up" buttons (Security/Classification) from primary to secondary variant. <img width="2056" height="1047" alt="Screenshot 2026-07-20 at 7 27 56 PM" src="https://github.com/user-attachments/assets/8625b274-b52a-47be-9052-80ac3d32dd93" />
This commit is contained in:
@@ -6744,22 +6744,6 @@ title = "Invite teammates"
|
||||
blurb = "{{active}} active · {{recommended}} recommended"
|
||||
title = "Confirm your policies"
|
||||
|
||||
[portal.home.quickActions]
|
||||
subtitle = "Top tasks for today"
|
||||
title = "Quick actions"
|
||||
|
||||
[portal.home.quickActions.buildPipeline]
|
||||
blurb = "3-step composer over the typed op library"
|
||||
title = "Build a pipeline"
|
||||
|
||||
[portal.home.quickActions.connectSource]
|
||||
blurb = "S3, agents, webhooks, watched folders"
|
||||
title = "Connect a source"
|
||||
|
||||
[portal.home.quickActions.issueApiKey]
|
||||
blurb = "Scoped key with rate limits and IP allowlist"
|
||||
title = "Issue an API key"
|
||||
|
||||
[portal.infrastructure]
|
||||
manageEditorDeployment = "Manage Editor deployment"
|
||||
sectionsAriaLabel = "Infrastructure sections"
|
||||
@@ -7531,35 +7515,9 @@ setUp = "Set up {{category}} policy"
|
||||
[portal.policies.wizard.workflow]
|
||||
description = "Choose what this policy does to every document it processes."
|
||||
|
||||
[portal.policySummary]
|
||||
activeSummary = "{{active}} / {{total}} active"
|
||||
noRule = "No rule enforced yet"
|
||||
subtitle = "Standing automations every document passes through, regardless of which pipeline handles it."
|
||||
title = "What runs on your PDFs"
|
||||
|
||||
[portal.policySummary.action]
|
||||
comingSoon = "Upgrade to Enterprise"
|
||||
configure = "Configure"
|
||||
setUp = "Set up"
|
||||
|
||||
[portal.policySummary.column]
|
||||
activeRule = "Active rule"
|
||||
policy = "Policy"
|
||||
status = "Status"
|
||||
|
||||
[portal.policySummary.empty]
|
||||
description = "Once policies are configured, the categories appear here."
|
||||
title = "No policies yet"
|
||||
|
||||
[portal.policySummary.state]
|
||||
active = "Active"
|
||||
off = "Off"
|
||||
soon = "Soon"
|
||||
|
||||
[portal.processingStatus]
|
||||
managePlan = "Manage plan"
|
||||
volumeSuffix = "PDFs processed · last 30 days"
|
||||
|
||||
[portal.processorFlow]
|
||||
footnote = "Counts are over the last 24 hours. Flow speed is illustrative."
|
||||
liveBadge = "Live"
|
||||
@@ -7878,14 +7836,6 @@ homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutab
|
||||
homeCta = "Start Trial →"
|
||||
homeHeadline = "Process millions of PDFs."
|
||||
|
||||
[portal.recentActivity]
|
||||
title = "Recent activity"
|
||||
viewAll = "View all"
|
||||
|
||||
[portal.recentActivity.empty]
|
||||
description = "Pipeline runs, deploys and agent events will appear here."
|
||||
title = "Nothing here yet"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
.portal-policysum__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.125rem 0.875rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-policysum__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-policysum__sub {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.portal-policysum__cat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-policysum__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.portal-policysum__cat-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policysum__cat-text strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-policysum__cat-text span {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-policysum__rule {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-policysum__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.125rem;
|
||||
}
|
||||
|
||||
.portal-policysum__loading-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { PolicySummary } from "@portal/components/PolicySummary";
|
||||
|
||||
const meta: Meta<typeof PolicySummary> = {
|
||||
title: "Portal/Home/PolicySummary",
|
||||
component: PolicySummary,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PolicySummary>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/policies", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/policies", () =>
|
||||
HttpResponse.json({
|
||||
summary: { active: 0, paused: 0, categories: 0, docsEnforced: 0 },
|
||||
catalogue: [],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchPolicies,
|
||||
type CatalogueEntry,
|
||||
type PoliciesResponse,
|
||||
} from "@portal/api/policies";
|
||||
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
|
||||
import "@portal/components/PolicySummary.css";
|
||||
|
||||
/**
|
||||
* Each row's display state collapses a category's facts into one of three
|
||||
* mutually-exclusive shapes:
|
||||
*
|
||||
* - `locked` — a coming-soon category; show a "Soon" affordance
|
||||
* - `active` — a configured policy that's enabled; offer "Configure"
|
||||
* - `off` — available but not set up (or paused); offer "Set up"
|
||||
*/
|
||||
type RowState = "locked" | "active" | "off";
|
||||
|
||||
interface PolicyRow {
|
||||
entry: CatalogueEntry;
|
||||
state: RowState;
|
||||
}
|
||||
|
||||
const STATE_BADGE: Record<
|
||||
RowState,
|
||||
{ tone: "success" | "neutral" | "info"; labelKey: string }
|
||||
> = {
|
||||
active: { tone: "success", labelKey: "portal.policySummary.state.active" },
|
||||
off: { tone: "neutral", labelKey: "portal.policySummary.state.off" },
|
||||
locked: { tone: "info", labelKey: "portal.policySummary.state.soon" },
|
||||
};
|
||||
|
||||
function toRow(entry: CatalogueEntry): PolicyRow {
|
||||
if (entry.category.comingSoon) return { entry, state: "locked" };
|
||||
const active = entry.policy?.state.status === "active";
|
||||
return { entry, state: active ? "active" : "off" };
|
||||
}
|
||||
|
||||
export function PolicySummary() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), []);
|
||||
const { data } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
const goToPolicies = () => setActiveView("policies");
|
||||
|
||||
const columns: TableColumn<PolicyRow>[] = [
|
||||
{
|
||||
key: "category",
|
||||
header: t("portal.policySummary.column.policy"),
|
||||
render: ({ entry }) => (
|
||||
<div className="portal-policysum__cat">
|
||||
<PolicyCategoryBadge category={entry.category} />
|
||||
<div className="portal-policysum__cat-text">
|
||||
<strong>{t(entry.category.label)}</strong>
|
||||
<span>{t(entry.category.desc)}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("portal.policySummary.column.status"),
|
||||
width: "7rem",
|
||||
render: ({ state }) => {
|
||||
const badge = STATE_BADGE[state];
|
||||
return (
|
||||
<StatusBadge tone={badge.tone} size="sm">
|
||||
{t(badge.labelKey)}
|
||||
</StatusBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "rule",
|
||||
header: t("portal.policySummary.column.activeRule"),
|
||||
render: ({ entry, state }) => (
|
||||
<span className="portal-policysum__rule">
|
||||
{state === "active"
|
||||
? t(entry.config.summary)
|
||||
: t("portal.policySummary.noRule")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "9rem",
|
||||
render: ({ state }) => {
|
||||
if (state === "locked") {
|
||||
return (
|
||||
<Button size="sm" variant="tertiary" onClick={goToPolicies}>
|
||||
{t("portal.policySummary.action.comingSoon")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={state === "active" ? "tertiary" : "secondary"}
|
||||
onClick={goToPolicies}
|
||||
>
|
||||
{state === "active"
|
||||
? t("portal.policySummary.action.configure")
|
||||
: t("portal.policySummary.action.setUp")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows: PolicyRow[] = data?.catalogue?.map(toRow) ?? [];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="portal-policysum"
|
||||
aria-label={t("portal.policySummary.title")}
|
||||
>
|
||||
<Card padding="none">
|
||||
<header className="portal-policysum__head">
|
||||
<div>
|
||||
<h2 className="portal-policysum__title">
|
||||
{t("portal.policySummary.title")}
|
||||
</h2>
|
||||
<p className="portal-policysum__sub">
|
||||
{t("portal.policySummary.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{data?.summary && (
|
||||
<StatusBadge tone="info" size="sm">
|
||||
{t("portal.policySummary.activeSummary", {
|
||||
active: data.summary.active,
|
||||
total: data.summary.categories,
|
||||
})}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-policysum__loading" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="portal-policysum__loading-row">
|
||||
<Skeleton width="9rem" />
|
||||
<Skeleton width="60%" height="0.625rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.policySummary.empty.title")}
|
||||
description={t("portal.policySummary.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && rows.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.entry.category.id}
|
||||
onRowClick={goToPolicies}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/* Single inline row: plan + real processed-PDF volume + manage-plan shortcut. */
|
||||
.portal-statusstrip--paid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-statusstrip__plan {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-statusstrip__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-statusstrip__sep {
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-statusstrip__volume {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-statusstrip__volume strong {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-statusstrip__manage {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
|
||||
|
||||
const meta: Meta<typeof ProcessingStatusStrip> = {
|
||||
title: "Portal/Home/ProcessingStatusStrip",
|
||||
component: ProcessingStatusStrip,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProcessingStatusStrip>;
|
||||
|
||||
/** Switch the Tier toolbar to compare the free meter vs the paid plan row. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Free tier pushed near its cap so the meter and upgrade nudge turn amber. */
|
||||
export const FreeNearCap: Story = {
|
||||
globals: { tier: "free" },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/home/kpis", async () => {
|
||||
await delay(80);
|
||||
return HttpResponse.json([{ value: "472 / 500" }]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Skeleton } from "@app/ui";
|
||||
import { TIER_INFO, useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats";
|
||||
import "@portal/components/ProcessingStatusStrip.css";
|
||||
|
||||
/**
|
||||
* A thin one-line header above the home content: current plan + the real 30-day
|
||||
* processed-PDF volume (from the fleet-usage endpoint), with a shortcut to the
|
||||
* Usage page. Renders "—" while loading or when the backend can't compute the
|
||||
* figure (e.g. EE auditing disabled) — never a fabricated number.
|
||||
*/
|
||||
export function ProcessingStatusStrip() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const { data, loading } = useAsync<FleetStats>(() => fetchFleetStats(), []);
|
||||
|
||||
return (
|
||||
<div className="portal-statusstrip portal-statusstrip--paid">
|
||||
<span className="portal-statusstrip__plan">
|
||||
<span
|
||||
className="portal-statusstrip__dot"
|
||||
style={{ background: TIER_INFO[tier].dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
{t(TIER_INFO[tier].labelKey)}
|
||||
</span>
|
||||
<span className="portal-statusstrip__sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-statusstrip__volume">
|
||||
{loading ? (
|
||||
<Skeleton width="3rem" height="0.875rem" />
|
||||
) : (
|
||||
<strong>{data?.pdfsProcessed?.toLocaleString() ?? "—"}</strong>
|
||||
)}{" "}
|
||||
{t("portal.processingStatus.volumeSuffix")}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
className="portal-statusstrip__manage"
|
||||
onClick={() => setActiveView("usage")}
|
||||
>
|
||||
{t("portal.processingStatus.managePlan")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
.portal-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-activity__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-activity__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-activity__more {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-activity__more:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-activity__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.portal-activity__item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-activity__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.portal-activity__item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-activity__rail {
|
||||
width: 0.1875rem;
|
||||
border-radius: var(--radius-pill);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-activity__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-activity__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-activity__action {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-activity__time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-activity__subject {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 0.0625rem;
|
||||
}
|
||||
|
||||
.portal-activity__detail-row {
|
||||
margin-top: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-activity__detail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-activity__item--skeleton {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.portal-activity__item--skeleton .portal-activity__rail {
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-activity__skel {
|
||||
height: 0.75rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-muted) 0%,
|
||||
var(--color-bg-hover) 50%,
|
||||
var(--color-bg-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-activity__skel--md {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.portal-activity__skel--sm {
|
||||
width: 50%;
|
||||
height: 0.625rem;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { RecentActivity } from "@portal/components/RecentActivity";
|
||||
|
||||
const meta: Meta<typeof RecentActivity> = {
|
||||
title: "Portal/Home/RecentActivity",
|
||||
component: RecentActivity,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "40rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RecentActivity>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/activity", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.get("/v1/activity", () => HttpResponse.json([]))],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, EmptyState, Skeleton, StatusBadge } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchAuditLog,
|
||||
type AuditLogResponse,
|
||||
type AuditStatus,
|
||||
} from "@portal/api/infrastructure";
|
||||
import {
|
||||
AUDIT_CAT_LABEL,
|
||||
AUDIT_STATUS_LABEL,
|
||||
AUDIT_TONE,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
import "@portal/components/RecentActivity.css";
|
||||
|
||||
/** Rail colour by outcome — mirrors the audit status tones. */
|
||||
const STATUS_COLOUR: Record<AuditStatus, string> = {
|
||||
success: "var(--color-green)",
|
||||
warning: "var(--color-amber)",
|
||||
danger: "var(--color-red)",
|
||||
info: "var(--color-blue)",
|
||||
};
|
||||
|
||||
/** How many of the most recent audit events the home card shows. */
|
||||
const MAX_EVENTS = 6;
|
||||
|
||||
/**
|
||||
* The home "Recent activity" card, backed by the real audit log (the same
|
||||
* endpoint the Infrastructure → Audit tab reads). Shows the latest few events;
|
||||
* "View all" jumps to the full audit view. Failures and an empty log both fall
|
||||
* through to the empty state — never an error banner on the dashboard.
|
||||
*/
|
||||
export function RecentActivity() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const { data, loading } = useAsync<AuditLogResponse>(
|
||||
() => fetchAuditLog(tier),
|
||||
[tier],
|
||||
);
|
||||
|
||||
const events = data?.events.slice(0, MAX_EVENTS) ?? [];
|
||||
const isLoading = loading && data === null;
|
||||
const isEmpty = !isLoading && events.length === 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="none"
|
||||
className="portal-activity"
|
||||
aria-label={t("portal.recentActivity.title")}
|
||||
>
|
||||
<header className="portal-activity__head">
|
||||
<h2 className="portal-activity__title">
|
||||
{t("portal.recentActivity.title")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="quiet"
|
||||
type="button"
|
||||
className="portal-activity__more"
|
||||
onClick={() => setActiveView("infrastructure")}
|
||||
>
|
||||
{t("portal.recentActivity.viewAll")} →
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{isLoading && (
|
||||
<ul className="portal-activity__list" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="portal-activity__item portal-activity__item--skeleton"
|
||||
>
|
||||
<span className="portal-activity__rail" />
|
||||
<div className="portal-activity__body">
|
||||
<Skeleton width="70%" />
|
||||
<Skeleton width="50%" height="0.625rem" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.recentActivity.empty.title")}
|
||||
description={t("portal.recentActivity.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ol className="portal-activity__list">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="portal-activity__item">
|
||||
<span
|
||||
className="portal-activity__rail"
|
||||
style={{ background: STATUS_COLOUR[event.status] }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="portal-activity__body">
|
||||
<div className="portal-activity__row">
|
||||
<span className="portal-activity__action">
|
||||
{event.action}
|
||||
</span>
|
||||
<span className="portal-activity__time">
|
||||
{event.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-activity__subject">{event.target}</div>
|
||||
<div className="portal-activity__detail-row">
|
||||
<span className="portal-activity__detail">
|
||||
{t(AUDIT_CAT_LABEL[event.category])} · {event.actor}
|
||||
</span>
|
||||
<StatusBadge tone={AUDIT_TONE[event.status]} size="sm">
|
||||
{t(AUDIT_STATUS_LABEL[event.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -59,8 +59,10 @@ export function FlowPolicies({
|
||||
) : policy.state === "off" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
px="sm"
|
||||
py="xs"
|
||||
variant="primary"
|
||||
fontSize="xs"
|
||||
variant="secondary"
|
||||
className="portal-pf__setup"
|
||||
onClick={() => onSetup(policy.key)}
|
||||
>
|
||||
|
||||
@@ -1,133 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { HomeHero } from "@portal/components/HomeHero";
|
||||
import { HomeGreeting } from "@portal/components/HomeGreeting";
|
||||
import { ProcessorFlow } from "@portal/components/ProcessorFlow";
|
||||
import { RecentActivity } from "@portal/components/RecentActivity";
|
||||
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
|
||||
import { PolicySummary } from "@portal/components/PolicySummary";
|
||||
import "@portal/views/Home.css";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Quick actions card */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** A stroke icon used inside the quick-action badge (replaces the old text
|
||||
glyphs ⌃ ⇢ ⚙, which rendered off-style vs the portal's icon set). */
|
||||
function QuickIcon({ d }: { d: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rows for the Quick Actions list. Each `view` navigates the portal. */
|
||||
const QUICK_ACTIONS: Array<{
|
||||
key: string;
|
||||
iconD: string;
|
||||
bg: string;
|
||||
fg: string;
|
||||
view: ViewId;
|
||||
}> = [
|
||||
{
|
||||
key: "buildPipeline",
|
||||
iconD:
|
||||
"M6 4a2 2 0 100 4 2 2 0 000-4zM18 16a2 2 0 100 4 2 2 0 000-4zM6 8v6a4 4 0 004 4h4",
|
||||
bg: "var(--color-purple-light)",
|
||||
fg: "var(--color-purple)",
|
||||
view: "pipelines",
|
||||
},
|
||||
{
|
||||
key: "connectSource",
|
||||
iconD:
|
||||
"M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1M14 11a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1",
|
||||
bg: "var(--color-green-light)",
|
||||
fg: "var(--color-green-dark)",
|
||||
view: "sources",
|
||||
},
|
||||
{
|
||||
key: "issueApiKey",
|
||||
iconD:
|
||||
"M2.6 17.4A2 2 0 002 18.8V21a1 1 0 001 1h3a1 1 0 001-1v-1a1 1 0 011-1h1a1 1 0 001-1v-1a1 1 0 011-1h.2a2 2 0 001.4-.6l.8-.8a6.5 6.5 0 10-4-4z M16.5 7.5 h.01",
|
||||
bg: "var(--color-amber-light)",
|
||||
fg: "var(--color-amber-dark)",
|
||||
view: "infrastructure",
|
||||
},
|
||||
];
|
||||
|
||||
function QuickActions() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
<Card padding="loose" className="portal-home__quick">
|
||||
<div className="portal-home__quick-head">
|
||||
<h2 className="portal-home__quick-title">
|
||||
{t("portal.home.quickActions.title")}
|
||||
</h2>
|
||||
<span className="portal-home__quick-sub">
|
||||
{t("portal.home.quickActions.subtitle")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-home__quick-list">
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
fullWidth
|
||||
px="sm"
|
||||
py="sm"
|
||||
type="button"
|
||||
className="portal-home__quick-row"
|
||||
onClick={() => setActiveView(action.view)}
|
||||
leftSection={
|
||||
<span
|
||||
className="portal-home__quick-icon"
|
||||
style={{ background: action.bg, color: action.fg }}
|
||||
aria-hidden
|
||||
>
|
||||
<QuickIcon d={action.iconD} />
|
||||
</span>
|
||||
}
|
||||
rightSection={
|
||||
<span className="portal-home__quick-arrow" aria-hidden>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="portal-home__quick-text">
|
||||
<strong>
|
||||
{t(`portal.home.quickActions.${action.key}.title`)}
|
||||
</strong>
|
||||
<span>{t(`portal.home.quickActions.${action.key}.blurb`)}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Home view */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -144,15 +20,6 @@ export function Home() {
|
||||
deal is underway (a bolt-on to any tier), otherwise the setup checklist. */}
|
||||
<HomeHero tier={tier} />
|
||||
<ProcessorFlow />
|
||||
|
||||
{/* One unified layout across tiers: real processed-PDF volume, real audit
|
||||
activity, quick actions, and the standing-policy summary. */}
|
||||
<ProcessingStatusStrip />
|
||||
<div className="portal-home__row">
|
||||
<RecentActivity />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<PolicySummary />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user