catch up
This commit is contained in:
@@ -12,7 +12,7 @@ POSTGRES_DB=pulsescore
|
||||
PORT=8080
|
||||
|
||||
# CORS — comma-separated list of allowed origins
|
||||
CORS_ALLOWED_ORIGINS=https://yourdomain.com
|
||||
CORS_ALLOWED_ORIGINS=https://galdr.subcult.tv
|
||||
|
||||
# Rate limiting — requests per minute per IP
|
||||
RATE_LIMIT_RPM=100
|
||||
@@ -21,7 +21,7 @@ RATE_LIMIT_RPM=100
|
||||
STRIPE_BILLING_SECRET_KEY=
|
||||
STRIPE_BILLING_PUBLISHABLE_KEY=
|
||||
STRIPE_BILLING_WEBHOOK_SECRET=
|
||||
STRIPE_BILLING_PORTAL_RETURN_URL=https://yourdomain.com/settings/billing
|
||||
STRIPE_BILLING_PORTAL_RETURN_URL=https://galdr.subcult.tv/settings/billing
|
||||
STRIPE_BILLING_PRICE_GROWTH_MONTHLY=
|
||||
STRIPE_BILLING_PRICE_GROWTH_ANNUAL=
|
||||
STRIPE_BILLING_PRICE_SCALE_MONTHLY=
|
||||
|
||||
@@ -24,8 +24,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
PORT: "8080"
|
||||
ENVIRONMENT: production
|
||||
@@ -37,6 +35,7 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulsescore-net
|
||||
- web
|
||||
|
||||
web:
|
||||
build:
|
||||
@@ -44,12 +43,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- pulsescore-net
|
||||
- web
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -57,3 +54,5 @@ volumes:
|
||||
networks:
|
||||
pulsescore-net:
|
||||
driver: bridge
|
||||
web:
|
||||
external: true
|
||||
|
||||
6
go.mod
6
go.mod
@@ -9,7 +9,10 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/stripe/stripe-go/v81 v81.4.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -17,10 +20,7 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/stripe/stripe-go/v81 v81.4.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -37,6 +37,8 @@ github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaD
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
17
web/package-lock.json
generated
17
web/package-lock.json
generated
@@ -65,6 +65,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1750,6 +1751,7 @@
|
||||
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1760,6 +1762,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1825,6 +1828,7 @@
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
@@ -2102,6 +2106,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2227,6 +2232,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2721,6 +2727,7 @@
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3888,6 +3895,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3970,6 +3978,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3979,6 +3988,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3998,6 +4008,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -4098,7 +4109,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -4322,6 +4334,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4438,6 +4451,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4559,6 +4573,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
108
web/src/components/AtRiskCustomersTable.tsx
Normal file
108
web/src/components/AtRiskCustomersTable.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import api from "@/lib/api";
|
||||
import { formatCurrency, relativeTime } from "@/lib/format";
|
||||
import HealthScoreBadge from "@/components/HealthScoreBadge";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface AtRiskCustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
health_score: number;
|
||||
risk_level: "green" | "yellow" | "red";
|
||||
mrr: number;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export default function AtRiskCustomersTable() {
|
||||
const [customers, setCustomers] = useState<AtRiskCustomer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const { data } = await api.get("/customers", {
|
||||
params: {
|
||||
sort: "health_score",
|
||||
order: "asc",
|
||||
per_page: 5,
|
||||
},
|
||||
});
|
||||
const list = data.customers ?? data.data ?? data ?? [];
|
||||
setCustomers(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setCustomers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
At-Risk Customers
|
||||
</h3>
|
||||
<Link
|
||||
to="/customers?sort=health_score&order=asc"
|
||||
className="text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : customers.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No at-risk customers
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200 text-xs uppercase text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th className="pb-2 pr-4">Name</th>
|
||||
<th className="pb-2 pr-4">Score</th>
|
||||
<th className="pb-2 pr-4">MRR</th>
|
||||
<th className="pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => navigate(`/customers/${c.id}`)}
|
||||
className="cursor-pointer border-b border-gray-100 last:border-0 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800"
|
||||
>
|
||||
<td className="py-3 pr-4 font-medium text-gray-900 dark:text-gray-100">
|
||||
{c.name}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<HealthScoreBadge
|
||||
score={c.health_score}
|
||||
riskLevel={c.risk_level}
|
||||
size="sm"
|
||||
showLabel
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-gray-700 dark:text-gray-300">
|
||||
{formatCurrency(c.mrr)}
|
||||
</td>
|
||||
<td className="py-3 text-gray-500 dark:text-gray-400">
|
||||
{relativeTime(c.last_seen_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,35 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import HealthScoreBadge from "@/components/HealthScoreBadge";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
} from "lucide-react";
|
||||
import { formatCurrency, relativeTime } from "@/lib/format";
|
||||
|
||||
const riskConfig = {
|
||||
green: {
|
||||
label: "Healthy",
|
||||
Icon: ShieldCheck,
|
||||
className:
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
},
|
||||
yellow: {
|
||||
label: "At Risk",
|
||||
Icon: ShieldAlert,
|
||||
className:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
},
|
||||
red: {
|
||||
label: "Critical",
|
||||
Icon: ShieldX,
|
||||
className:
|
||||
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
@@ -20,27 +49,6 @@ interface CustomerTableProps {
|
||||
onSort: (field: string) => void;
|
||||
}
|
||||
|
||||
function formatCurrency(cents: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
if (!dateStr) return "—";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "company", label: "Company" },
|
||||
@@ -122,17 +130,18 @@ export default function CustomerTable({
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
c.risk_level === "green"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: c.risk_level === "yellow"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{c.risk_level}
|
||||
</span>
|
||||
{(() => {
|
||||
const config = riskConfig[c.risk_level];
|
||||
const Icon = config.Icon;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${config.className}`}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
|
||||
{relativeTime(c.last_seen_at)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function EmptyState({
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="mb-4 text-gray-400 dark:text-gray-500">
|
||||
<div className="mb-4 text-gray-400 dark:text-gray-500" aria-hidden="true">
|
||||
{icon ?? <Inbox className="h-12 w-12" />}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Circle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { relativeTime } from "@/lib/format";
|
||||
|
||||
interface CustomerEvent {
|
||||
id: string;
|
||||
@@ -36,19 +37,6 @@ const eventIcons: Record<string, { icon: typeof CheckCircle; color: string }> =
|
||||
"ticket.opened": { icon: Flag, color: "text-yellow-500" },
|
||||
};
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
if (!dateStr) return "—";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function EventTimeline({ customerId }: { customerId: string }) {
|
||||
const [events, setEvents] = useState<CustomerEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { LogOut, User, Sun, Moon, Monitor } from "lucide-react";
|
||||
import { LogOut, User, Sun, Moon, Monitor, Menu } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import NotificationBell from "@/components/NotificationBell";
|
||||
|
||||
export default function Header() {
|
||||
interface HeaderProps {
|
||||
onOpenMobileNav?: () => void;
|
||||
}
|
||||
|
||||
export default function Header({ onOpenMobileNav }: HeaderProps) {
|
||||
const { user, organization, logout } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
@@ -22,6 +26,15 @@ export default function Header() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [menuOpen]);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate("/login");
|
||||
@@ -45,6 +58,15 @@ export default function Header() {
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900 sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenMobileNav && (
|
||||
<button
|
||||
onClick={onOpenMobileNav}
|
||||
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 md:hidden"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{organization && (
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{organization.name}
|
||||
@@ -59,6 +81,7 @@ export default function Header() {
|
||||
onClick={cycleTheme}
|
||||
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
title={`Theme: ${theme}`}
|
||||
aria-label={`Toggle theme, current: ${theme}`}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
@@ -67,6 +90,7 @@ export default function Header() {
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
aria-label="User menu"
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300">
|
||||
{user?.first_name?.[0]}
|
||||
|
||||
@@ -2,6 +2,7 @@ interface HealthScoreBadgeProps {
|
||||
score: number;
|
||||
riskLevel?: "green" | "yellow" | "red";
|
||||
size?: "sm" | "md" | "lg";
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -16,6 +17,12 @@ const colorClasses = {
|
||||
red: "bg-red-500 dark:bg-red-600",
|
||||
};
|
||||
|
||||
const riskLabels = {
|
||||
green: "Healthy",
|
||||
yellow: "At Risk",
|
||||
red: "Critical",
|
||||
};
|
||||
|
||||
function deriveRiskLevel(score: number): "green" | "yellow" | "red" {
|
||||
if (score >= 70) return "green";
|
||||
if (score >= 40) return "yellow";
|
||||
@@ -26,15 +33,28 @@ export default function HealthScoreBadge({
|
||||
score,
|
||||
riskLevel,
|
||||
size = "md",
|
||||
showLabel = false,
|
||||
}: HealthScoreBadgeProps) {
|
||||
const level = riskLevel ?? deriveRiskLevel(score);
|
||||
const label = riskLabels[level];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full font-semibold text-white ${sizeClasses[size]} ${colorClasses[level]}`}
|
||||
title={`Health score: ${score}`}
|
||||
className="inline-flex items-center gap-2"
|
||||
role="img"
|
||||
aria-label={`Health score: ${score}, ${label}`}
|
||||
>
|
||||
{score}
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full font-semibold text-white ${sizeClasses[size]} ${colorClasses[level]}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{!showLabel && <span className="sr-only">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import IntegrationStatusBadge from "@/components/IntegrationStatusBadge";
|
||||
import { RefreshCw, Unplug } from "lucide-react";
|
||||
import { relativeTime } from "@/lib/format";
|
||||
|
||||
interface IntegrationCardProps {
|
||||
provider: string;
|
||||
@@ -10,18 +11,6 @@ interface IntegrationCardProps {
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
function relativeTime(dateStr?: string): string {
|
||||
if (!dateStr) return "Never";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export default function IntegrationCard({
|
||||
provider,
|
||||
status,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Bell } from "lucide-react";
|
||||
import { notificationsApi, type AppNotification } from "@/lib/api";
|
||||
import { relativeTime } from "@/lib/format";
|
||||
|
||||
const POLL_INTERVAL = 30_000;
|
||||
|
||||
@@ -40,6 +41,16 @@ export default function NotificationBell() {
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open]);
|
||||
|
||||
async function toggleOpen() {
|
||||
if (!open) {
|
||||
setLoading(true);
|
||||
@@ -85,16 +96,6 @@ export default function NotificationBell() {
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
@@ -156,7 +157,7 @@ export default function NotificationBell() {
|
||||
{n.message}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{timeAgo(n.created_at)}
|
||||
{relativeTime(n.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -86,6 +85,7 @@ export default function Sidebar({
|
||||
<button
|
||||
onClick={onCloseMobile}
|
||||
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -121,6 +121,7 @@ export default function Sidebar({
|
||||
onClick={onToggleCollapse}
|
||||
className="flex w-full items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
@@ -131,14 +132,6 @@ export default function Sidebar({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile hamburger (rendered in header area but controlled here) */}
|
||||
<button
|
||||
onClick={() => (mobileOpen ? onCloseMobile() : onToggleCollapse())}
|
||||
className="fixed left-4 top-4 z-30 rounded-lg bg-white p-2 shadow-md dark:bg-gray-800 md:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<Menu className="h-5 w-5 text-gray-700 dark:text-gray-300" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ interface StatCardProps {
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const trendLabels = {
|
||||
up: "increased",
|
||||
down: "decreased",
|
||||
neutral: "unchanged",
|
||||
};
|
||||
|
||||
export default function StatCard({
|
||||
title,
|
||||
value,
|
||||
@@ -37,7 +43,9 @@ export default function StatCard({
|
||||
{title}
|
||||
</p>
|
||||
{icon && (
|
||||
<span className="text-gray-400 dark:text-gray-500">{icon}</span>
|
||||
<span className="text-gray-400 dark:text-gray-500" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
@@ -45,8 +53,10 @@ export default function StatCard({
|
||||
</p>
|
||||
{trend !== undefined && (
|
||||
<div className={`mt-2 flex items-center gap-1 text-sm ${trendColor}`}>
|
||||
<TrendIcon className="h-4 w-4" />
|
||||
<TrendIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{Math.abs(trend)}%</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">vs last period</span>
|
||||
<span className="sr-only">{trendLabels[trendDirection]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ export default function Toast({ type, message, onClose }: ToastProps) {
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`shrink-0 ${text} hover:opacity-70`}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -11,6 +11,7 @@ import api from "@/lib/api";
|
||||
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
interface MRRPoint {
|
||||
date: string;
|
||||
@@ -20,14 +21,6 @@ interface MRRPoint {
|
||||
const ranges = ["7d", "30d", "90d", "1y"] as const;
|
||||
type Range = (typeof ranges)[number];
|
||||
|
||||
function formatCurrency(cents: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
@@ -79,14 +72,26 @@ export default function MRRTrendChart() {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
MRR Trend
|
||||
</h3>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
MRR Trend
|
||||
</h3>
|
||||
{data.length >= 2 && (() => {
|
||||
const netChange = data[data.length - 1].mrr - data[0].mrr;
|
||||
const sign = netChange >= 0 ? "+" : "";
|
||||
return (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{sign}{formatCurrency(netChange)} over period
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
|
||||
@@ -110,20 +115,21 @@ export default function MRRTrendChart() {
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#9ca3af"
|
||||
stroke="var(--chart-axis-stroke)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatCurrency(v)}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#9ca3af"
|
||||
stroke="var(--chart-axis-stroke)"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(value as number), "MRR"]}
|
||||
labelFormatter={(label) => formatDate(String(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--color-white, #fff)",
|
||||
borderColor: "#e5e7eb",
|
||||
backgroundColor: "var(--chart-tooltip-bg)",
|
||||
borderColor: "var(--chart-tooltip-border)",
|
||||
borderRadius: 8,
|
||||
color: "var(--chart-tooltip-text)",
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
@@ -132,6 +138,7 @@ export default function MRRTrendChart() {
|
||||
stroke="#6366f1"
|
||||
strokeWidth={2}
|
||||
fill="url(#mrrGradient)"
|
||||
activeDot={{ r: 6, strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
134
web/src/components/charts/RiskDistributionChart.tsx
Normal file
134
web/src/components/charts/RiskDistributionChart.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import api from "@/lib/api";
|
||||
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { PieChart as PieChartIcon } from "lucide-react";
|
||||
|
||||
interface BucketData {
|
||||
range: string;
|
||||
count: number;
|
||||
min_score: number;
|
||||
max_score: number;
|
||||
}
|
||||
|
||||
interface RiskSegment {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function aggregateRisk(buckets: BucketData[]): RiskSegment[] {
|
||||
let healthy = 0;
|
||||
let atRisk = 0;
|
||||
let critical = 0;
|
||||
|
||||
for (const b of buckets) {
|
||||
if (b.min_score >= 70) {
|
||||
healthy += b.count;
|
||||
} else if (b.min_score >= 40) {
|
||||
atRisk += b.count;
|
||||
} else {
|
||||
critical += b.count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ name: "Healthy", value: healthy, color: "#22c55e" },
|
||||
{ name: "At Risk", value: atRisk, color: "#eab308" },
|
||||
{ name: "Critical", value: critical, color: "#ef4444" },
|
||||
].filter((s) => s.value > 0);
|
||||
}
|
||||
|
||||
export default function RiskDistributionChart() {
|
||||
const [segments, setSegments] = useState<RiskSegment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [empty, setEmpty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const { data: res } = await api.get("/dashboard/score-distribution");
|
||||
const buckets = res.buckets ?? res.distribution ?? res ?? [];
|
||||
if (Array.isArray(buckets) && buckets.length > 0) {
|
||||
const agg = aggregateRisk(buckets);
|
||||
if (agg.length > 0) {
|
||||
setSegments(agg);
|
||||
} else {
|
||||
setEmpty(true);
|
||||
}
|
||||
} else {
|
||||
setEmpty(true);
|
||||
}
|
||||
} catch {
|
||||
setEmpty(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
if (loading) return <ChartSkeleton />;
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
|
||||
<EmptyState
|
||||
icon={<PieChartIcon className="h-12 w-12" />}
|
||||
title="No risk data yet"
|
||||
description="Risk distribution will appear once customers are scored."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="mb-4 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Risk Distribution
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={segments}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
dataKey="value"
|
||||
label={({ name, value }) =>
|
||||
`${name}: ${value} (${Math.round((value / total) * 100)}%)`
|
||||
}
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<Cell key={s.name} fill={s.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--chart-tooltip-bg)",
|
||||
borderColor: "var(--chart-tooltip-border)",
|
||||
borderRadius: 8,
|
||||
color: "var(--chart-tooltip-text)",
|
||||
}}
|
||||
formatter={(value) => [
|
||||
`${value} (${Math.round(((value as number) / total) * 100)}%)`,
|
||||
"Customers",
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import api from "@/lib/api";
|
||||
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
|
||||
@@ -64,6 +65,8 @@ export default function ScoreDistributionChart() {
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="mb-4 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
@@ -71,27 +74,39 @@ export default function ScoreDistributionChart() {
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="range" tick={{ fontSize: 12 }} stroke="#9ca3af" />
|
||||
<XAxis dataKey="range" tick={{ fontSize: 12 }} stroke="var(--chart-axis-stroke)" />
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#9ca3af"
|
||||
stroke="var(--chart-axis-stroke)"
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--color-white, #fff)",
|
||||
borderColor: "#e5e7eb",
|
||||
backgroundColor: "var(--chart-tooltip-bg)",
|
||||
borderColor: "var(--chart-tooltip-border)",
|
||||
borderRadius: 8,
|
||||
color: "var(--chart-tooltip-text)",
|
||||
}}
|
||||
formatter={(value) => [value, "Customers"]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
key={entry.range}
|
||||
fill={getBarColor(entry.min_score ?? index * 10)}
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="count"
|
||||
position="top"
|
||||
formatter={(value: unknown) => {
|
||||
const v = value as number;
|
||||
return total > 0
|
||||
? `${v} (${Math.round((v / total) * 100)}%)`
|
||||
: `${v}`;
|
||||
}}
|
||||
style={{ fontSize: 11, fill: "var(--chart-tooltip-text)" }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -81,16 +81,17 @@ export default function ScoreHistoryChart({
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#9ca3af"
|
||||
stroke="var(--chart-axis-stroke)"
|
||||
/>
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} stroke="#9ca3af" />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} stroke="var(--chart-axis-stroke)" />
|
||||
<Tooltip
|
||||
formatter={(value) => [value, "Score"]}
|
||||
labelFormatter={(label) => formatDate(String(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--color-white, #fff)",
|
||||
borderColor: "#e5e7eb",
|
||||
backgroundColor: "var(--chart-tooltip-bg)",
|
||||
borderColor: "var(--chart-tooltip-border)",
|
||||
borderRadius: 8,
|
||||
color: "var(--chart-tooltip-text)",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--chart-tooltip-bg: #ffffff;
|
||||
--chart-tooltip-border: #e5e7eb;
|
||||
--chart-tooltip-text: #111827;
|
||||
--chart-axis-stroke: #9ca3af;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-tooltip-bg: #1f2937;
|
||||
--chart-tooltip-border: #374151;
|
||||
--chart-tooltip-text: #f3f4f6;
|
||||
--chart-axis-stroke: #6b7280;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,12 @@ export default function AppLayout() {
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => {
|
||||
if (window.innerWidth < 768) {
|
||||
openMobile();
|
||||
} else {
|
||||
toggleCollapse();
|
||||
}
|
||||
}}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
mobileOpen={mobileOpen}
|
||||
onCloseMobile={closeMobile}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<Header onOpenMobileNav={openMobile} />
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
29
web/src/lib/format.ts
Normal file
29
web/src/lib/format.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
interface FormatCurrencyOptions {
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
}
|
||||
|
||||
export function formatCurrency(
|
||||
cents: number,
|
||||
opts?: FormatCurrencyOptions,
|
||||
): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: opts?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: opts?.maximumFractionDigits ?? 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
export function relativeTime(dateStr?: string): string {
|
||||
if (!dateStr) return "—";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import EventTimeline from "@/components/EventTimeline";
|
||||
import ScoreHistoryChart from "@/components/charts/ScoreHistoryChart";
|
||||
import ProfileSkeleton from "@/components/skeletons/ProfileSkeleton";
|
||||
import { ChevronRight, Mail, Building, DollarSign, Clock } from "lucide-react";
|
||||
import { formatCurrency, relativeTime } from "@/lib/format";
|
||||
|
||||
interface CustomerDetail {
|
||||
id: string;
|
||||
@@ -38,26 +39,6 @@ interface ScoreFactor {
|
||||
|
||||
type Tab = "overview" | "events" | "subscriptions";
|
||||
|
||||
function formatCurrency(cents: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
if (!dateStr) return "—";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [customer, setCustomer] = useState<CustomerDetail | null>(null);
|
||||
|
||||
@@ -5,7 +5,10 @@ import StatCard from "@/components/StatCard";
|
||||
import CardSkeleton from "@/components/skeletons/CardSkeleton";
|
||||
import ScoreDistributionChart from "@/components/charts/ScoreDistributionChart";
|
||||
import MRRTrendChart from "@/components/charts/MRRTrendChart";
|
||||
import RiskDistributionChart from "@/components/charts/RiskDistributionChart";
|
||||
import AtRiskCustomersTable from "@/components/AtRiskCustomersTable";
|
||||
import { Users, AlertTriangle, DollarSign, Activity } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
interface DashboardSummary {
|
||||
total_customers: number;
|
||||
@@ -14,15 +17,6 @@ interface DashboardSummary {
|
||||
average_health_score: number;
|
||||
}
|
||||
|
||||
function formatCurrency(cents: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -62,7 +56,7 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950">
|
||||
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
Failed to load dashboard data.
|
||||
</p>
|
||||
@@ -103,6 +97,14 @@ export default function DashboardPage() {
|
||||
<ScoreDistributionChart />
|
||||
<MRRTrendChart />
|
||||
</div>
|
||||
|
||||
{/* Risk overview */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<RiskDistributionChart />
|
||||
<div className="lg:col-span-2">
|
||||
<AtRiskCustomersTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user