Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions apps/dashboard/src/components/bi/BiCharts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { BI, BI_SERIES, ROOM_STATUS_COLORS, chartTooltipStyle } from './chartTheme';

export function RoomStatusDonut({
data,
emptyLabel,
}: {
data: { name: string; status: string; value: number }[];
emptyLabel: string;
}) {
if (!data.length) {
return (
<div className="flex items-center justify-center h-56 text-sm text-telivity-mid-grey">
{emptyLabel}
</div>
);
}
return (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={58}
outerRadius={92}
dataKey="value"
nameKey="name"
paddingAngle={2}
stroke="none"
>
{data.map((entry, i) => (
<Cell key={i} fill={ROOM_STATUS_COLORS[entry.status] ?? BI.midGrey} />
))}
</Pie>
<Tooltip contentStyle={chartTooltipStyle} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
);
}

export function OccupancyTrendChart({
data,
emptyLabel,
valueLabel,
}: {
data: { date: string; occupancyPct: number }[];
emptyLabel: string;
valueLabel: string;
}) {
if (!data.length) {
return <p className="text-sm text-telivity-mid-grey py-16 text-center">{emptyLabel}</p>;
}
return (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={BI.grid} vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} />
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11, fill: BI.slate }}
tickLine={false}
axisLine={false}
width={36}
/>
<Tooltip
contentStyle={chartTooltipStyle}
formatter={(v: number) => [`${v.toFixed(1)}%`, valueLabel]}
/>
<Line
type="monotone"
dataKey="occupancyPct"
stroke={BI.teal}
strokeWidth={2.5}
dot={false}
activeDot={{ r: 4, fill: BI.teal }}
/>
</LineChart>
</ResponsiveContainer>
);
}

export function RevenueMixBars({
data,
currencyFmt,
emptyLabel,
}: {
data: { name: string; amount: number }[];
currencyFmt: (n: number) => string;
emptyLabel: string;
}) {
if (!data.length) {
return <p className="text-sm text-telivity-mid-grey py-12 text-center">{emptyLabel}</p>;
}
return (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 12, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={BI.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} />
<YAxis
type="category"
dataKey="name"
width={90}
tick={{ fontSize: 11, fill: BI.slate }}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={chartTooltipStyle}
formatter={(v: number) => [currencyFmt(v), '']}
/>
<Bar dataKey="amount" radius={[0, 6, 6, 0]} barSize={16}>
{data.map((_, i) => (
<Cell key={i} fill={BI_SERIES[i % BI_SERIES.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

export function PaymentMethodBars({
data,
currencyFmt,
emptyLabel,
}: {
data: { method: string; amount: number }[];
currencyFmt: (n: number) => string;
emptyLabel: string;
}) {
if (!data.length) {
return <p className="text-sm text-telivity-mid-grey py-12 text-center">{emptyLabel}</p>;
}
return (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={BI.grid} vertical={false} />
<XAxis dataKey="method" tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} width={48} />
<Tooltip
contentStyle={chartTooltipStyle}
formatter={(v: number) => [currencyFmt(v), '']}
/>
<Bar dataKey="amount" fill={BI.deepBlue} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}

export function PortfolioRevenueBars({
data,
currencyFmt,
revenueLabel,
}: {
data: { name: string; revenue: number }[];
currencyFmt: (n: number) => string;
revenueLabel: string;
}) {
if (!data.length) return null;
return (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={BI.grid} vertical={false} />
<XAxis dataKey="name" tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} width={48} />
<Tooltip
contentStyle={chartTooltipStyle}
formatter={(v: number) => [currencyFmt(v), revenueLabel]}
/>
<Bar dataKey="revenue" fill={BI.teal} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}

export function PaceDualLineChart({
data,
emptyLabel,
roomsLabel,
bookingsLabel,
}: {
data: { date: string; roomsOnBooks: number; newBookings: number }[];
emptyLabel: string;
roomsLabel: string;
bookingsLabel: string;
}) {
if (!data.length) {
return <p className="text-sm text-telivity-mid-grey py-16 text-center">{emptyLabel}</p>;
}
return (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={BI.grid} vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11, fill: BI.slate }} tickLine={false} axisLine={false} width={36} />
<Tooltip contentStyle={chartTooltipStyle} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line
type="monotone"
dataKey="roomsOnBooks"
name={roomsLabel}
stroke={BI.teal}
strokeWidth={2.5}
dot={false}
/>
<Line
type="monotone"
dataKey="newBookings"
name={bookingsLabel}
stroke={BI.navy}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
57 changes: 57 additions & 0 deletions apps/dashboard/src/components/bi/BiHero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import PeriodChips, { type BiPeriod } from './PeriodChips';

interface BiHeroProps {
eyebrow: string;
title: string;
subtitle: string;
dateLabel: string;
icon: LucideIcon;
period?: BiPeriod;
onPeriodChange?: (p: BiPeriod) => void;
periodLabels?: Record<BiPeriod, string>;
actions?: ReactNode;
}

export default function BiHero({
eyebrow,
title,
subtitle,
dateLabel,
icon: Icon,
period,
onPeriodChange,
periodLabels,
actions,
}: BiHeroProps) {
return (
<div className="relative overflow-hidden rounded-2xl mb-6 bi-hero">
<div className="absolute inset-0 bg-gradient-to-br from-telivity-navy via-[#2a3050] to-[#0d5c6e]" />
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
'radial-gradient(circle at 20% 20%, rgba(6,189,180,0.45), transparent 45%), radial-gradient(circle at 85% 10%, rgba(242,100,27,0.25), transparent 40%), radial-gradient(circle at 70% 80%, rgba(1,100,145,0.4), transparent 45%)',
}}
/>
<div className="relative px-5 sm:px-6 py-6 sm:py-7 flex flex-col lg:flex-row lg:items-end gap-5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-telivity-light-teal text-xs font-semibold uppercase tracking-[0.14em] mb-2">
<Icon size={14} />
<span>{eyebrow}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-semibold text-white tracking-tight">{title}</h1>
<p className="text-sm text-white/70 mt-1.5 max-w-2xl">{subtitle}</p>
<p className="text-xs text-white/50 mt-3 font-medium">{dateLabel}</p>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
{period && onPeriodChange && periodLabels && (
<PeriodChips value={period} onChange={onPeriodChange} labels={periodLabels} />
)}
{actions}
</div>
</div>
</div>
);
}
Loading
Loading