feat(website): rebuild dashboard visual faithful to twenty-front (#22218)
Rebuilds the product-feature **DashboardVisual** to mirror
twenty-front's dashboard widgets, with colors traced to twenty-front's
actual source rather than eyeballed.
## Widgets
- **Bar — "Deals by month"**: single-series `blue8` (twenty-front's
`GRAPH_DEFAULT_COLOR`), dashed `4 4` gridlines, value labels, nice
rounded Y-ticks. Replaces the old stacked bar (stacked bars aren't used
in twenty-front).
- **Donut — "Deals by stage"**: the real opportunity pipeline
(New/Screening/Meeting/Proposal/Customer), each segment colored by that
stage option's own color from the metadata
(`red/purple/sky/turquoise/yellow`), with a center total and a paginated
horizontal legend.
- **KPIs**: big-number cards (Revenue YTD / Avg deal size / Win rate).
## Responsive — `mediaUp('md')`
The dashboard is the full-width spotlight tile, whose frame is short
below md and grows to 420px at md+. So the layout keys off md: below it
collapses to **2-up KPIs + a full-width bar** (donut and the 3rd KPI
hidden, smaller breadcrumb), and the spotlight frame's mobile min-height
is bumped so the bar has room; at md+ the full 3-KPI + side-by-side
layout returns. The donut caps at its size and shrinks with its
container.
## Notes
- Follows the `product-feature` conventions (per-visual folder,
one-export-per-file, split types).
- `Tiles.tsx`: one-line spotlight mobile min-height bump (only the
dashboard uses the spotlight tile).
- typecheck + lint + build all green.
<img width="1148" height="638" alt="image"
src="https://github.com/user-attachments/assets/1eb8f120-88c2-45d1-adf9-ec00abe11006"
/>
This commit is contained in:
+76
-32
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconDotsVertical, IconLayoutDashboard } from '@tabler/icons-react';
|
||||
import { THEME_LIGHT } from 'twenty-ui/theme';
|
||||
|
||||
import { previewFontSize } from '@/app-preview/preview-font-size';
|
||||
import { mediaUp } from '@/tokens';
|
||||
|
||||
import { BarChart } from './components/BarChart';
|
||||
import { DonutChart } from './components/DonutChart';
|
||||
@@ -45,13 +48,21 @@ const BreadcrumbIcon = styled.span`
|
||||
|
||||
const BreadcrumbText = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.secondary};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.sm)};
|
||||
|
||||
${mediaUp('md')} {
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
|
||||
}
|
||||
`;
|
||||
|
||||
const BreadcrumbCurrent = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.primary};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.sm)};
|
||||
font-weight: ${THEME_LIGHT.font.weight.medium};
|
||||
|
||||
${mediaUp('md')} {
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
|
||||
}
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
@@ -83,15 +94,40 @@ const SearchChip = styled.span`
|
||||
|
||||
const Body = styled.div`
|
||||
background-color: ${THEME_LIGHT.background.primary};
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
`;
|
||||
|
||||
const KpiRow = styled.div`
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
& > :nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${mediaUp('md')} {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
& > :nth-child(3) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ChartRow = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const WidgetCard = styled.div`
|
||||
background-color: ${THEME_LIGHT.background.secondary};
|
||||
border: 1px solid ${THEME_LIGHT.border.color.light};
|
||||
@@ -99,15 +135,24 @@ const WidgetCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
|
||||
&[data-cell='bar'] {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
flex: 1;
|
||||
}
|
||||
&[data-cell='donut'] {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
display: none;
|
||||
}
|
||||
|
||||
${mediaUp('md')} {
|
||||
&[data-cell='bar'] {
|
||||
flex: 1.4;
|
||||
}
|
||||
&[data-cell='donut'] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -122,6 +167,8 @@ const WidgetHeader = styled.span`
|
||||
`;
|
||||
|
||||
export function DashboardVisual({ active }: { active: boolean }) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return (
|
||||
<Window>
|
||||
<Topbar>
|
||||
@@ -129,8 +176,10 @@ export function DashboardVisual({ active }: { active: boolean }) {
|
||||
<BreadcrumbIcon>
|
||||
<IconLayoutDashboard size={16} stroke={2} />
|
||||
</BreadcrumbIcon>
|
||||
<BreadcrumbText>Dashboards /</BreadcrumbText>
|
||||
<BreadcrumbCurrent>Sales performance</BreadcrumbCurrent>
|
||||
<BreadcrumbText>{i18n._(msg`Dashboards`)} /</BreadcrumbText>
|
||||
<BreadcrumbCurrent>
|
||||
{i18n._(msg`Sales performance`)}
|
||||
</BreadcrumbCurrent>
|
||||
</Breadcrumb>
|
||||
<Actions>
|
||||
<IconButton>
|
||||
@@ -140,28 +189,23 @@ export function DashboardVisual({ active }: { active: boolean }) {
|
||||
</Actions>
|
||||
</Topbar>
|
||||
<Body>
|
||||
{DASHBOARD_VISUAL_DATA.kpis.map((kpi) => (
|
||||
<WidgetCard key={kpi.label}>
|
||||
<WidgetHeader>{kpi.label}</WidgetHeader>
|
||||
<KpiCard kpi={kpi} />
|
||||
<KpiRow>
|
||||
{DASHBOARD_VISUAL_DATA.kpis.map((kpi) => (
|
||||
<WidgetCard key={kpi.id}>
|
||||
<KpiCard kpi={kpi} />
|
||||
</WidgetCard>
|
||||
))}
|
||||
</KpiRow>
|
||||
<ChartRow>
|
||||
<WidgetCard data-cell="bar">
|
||||
<WidgetHeader>{i18n._(msg`Deals by month`)}</WidgetHeader>
|
||||
<BarChart active={active} months={DASHBOARD_VISUAL_DATA.byMonth} />
|
||||
</WidgetCard>
|
||||
))}
|
||||
<WidgetCard data-cell="bar">
|
||||
<WidgetHeader>Deals by month</WidgetHeader>
|
||||
<BarChart
|
||||
active={active}
|
||||
months={DASHBOARD_VISUAL_DATA.byMonth}
|
||||
stages={DASHBOARD_VISUAL_DATA.stages}
|
||||
/>
|
||||
</WidgetCard>
|
||||
<WidgetCard data-cell="donut">
|
||||
<WidgetHeader>Deals by stage</WidgetHeader>
|
||||
<DonutChart
|
||||
active={active}
|
||||
stages={DASHBOARD_VISUAL_DATA.stages}
|
||||
values={DASHBOARD_VISUAL_DATA.stageTotals}
|
||||
/>
|
||||
</WidgetCard>
|
||||
<WidgetCard data-cell="donut">
|
||||
<WidgetHeader>{i18n._(msg`Deals by stage`)}</WidgetHeader>
|
||||
<DonutChart active={active} stages={DASHBOARD_VISUAL_DATA.stages} />
|
||||
</WidgetCard>
|
||||
</ChartRow>
|
||||
</Body>
|
||||
</Window>
|
||||
);
|
||||
|
||||
+94
-70
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { THEME_LIGHT } from 'twenty-ui/theme';
|
||||
|
||||
@@ -7,10 +8,10 @@ import { previewFontSize } from '@/app-preview/preview-font-size';
|
||||
import { EASING } from '@/tokens';
|
||||
|
||||
import { type DashboardMonth } from '../types/dashboard-month';
|
||||
import { type DashboardStage } from '../types/dashboard-stage';
|
||||
|
||||
const Y_TICK_COUNT = 5;
|
||||
const X_LABEL_ROW_HEIGHT = 18;
|
||||
const TARGET_TICK_COUNT = 4;
|
||||
const BAR_MAX_WIDTH = 26;
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
@@ -48,12 +49,25 @@ const Bars = styled.div`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const PlotArea = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const GridLine = styled.div`
|
||||
border-top: 1px dashed ${THEME_LIGHT.border.color.light};
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
const BarsRow = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const BarColumn = styled.div`
|
||||
@@ -62,41 +76,35 @@ const BarColumn = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Stack = styled.div`
|
||||
border-radius: 3px 3px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
min-height: 2px;
|
||||
overflow: hidden;
|
||||
const Bar = styled.div`
|
||||
background-color: ${THEME_LIGHT.color.blue8};
|
||||
border-radius: ${THEME_LIGHT.border.radius.sm} ${THEME_LIGHT.border.radius.sm}
|
||||
0 0;
|
||||
max-width: ${BAR_MAX_WIDTH}px;
|
||||
transform-origin: bottom;
|
||||
transition:
|
||||
transform 0.8s ${EASING.standard},
|
||||
filter 0.15s ease;
|
||||
width: 68%;
|
||||
width: 58%;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
`;
|
||||
|
||||
const Segment = styled.div`
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
|
||||
&[data-tone='blue'] {
|
||||
background-color: ${THEME_LIGHT.color.blue8};
|
||||
}
|
||||
&[data-tone='purple'] {
|
||||
background-color: ${THEME_LIGHT.color.purple8};
|
||||
}
|
||||
&[data-tone='turquoise'] {
|
||||
background-color: ${THEME_LIGHT.color.turquoise8};
|
||||
}
|
||||
&[data-tone='orange'] {
|
||||
background-color: ${THEME_LIGHT.color.orange8};
|
||||
}
|
||||
const ValueLabel = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.light};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.xs)};
|
||||
font-variant-numeric: tabular-nums;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
transform: translateY(-3px);
|
||||
transition: opacity 0.4s ${EASING.standard};
|
||||
`;
|
||||
|
||||
const XLabels = styled.div`
|
||||
@@ -113,67 +121,83 @@ const XLabel = styled.span`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
function getNiceScale(maxValue: number): { maxTick: number; ticks: number[] } {
|
||||
if (!(maxValue > 0)) {
|
||||
return { maxTick: 1, ticks: [0, 1] };
|
||||
}
|
||||
const rawStep = maxValue / TARGET_TICK_COUNT;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
||||
const normalized = rawStep / magnitude;
|
||||
const niceNormalized =
|
||||
normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
const step = niceNormalized * magnitude;
|
||||
const tickCount = Math.ceil(maxValue / step);
|
||||
return {
|
||||
maxTick: step * tickCount,
|
||||
ticks: Array.from({ length: tickCount + 1 }, (_, index) => index * step),
|
||||
};
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
active,
|
||||
months,
|
||||
stages,
|
||||
}: {
|
||||
active: boolean;
|
||||
months: DashboardMonth[];
|
||||
stages: DashboardStage[];
|
||||
}) {
|
||||
const monthlyTotals = months.map((month) =>
|
||||
month.values.reduce((sum, value) => sum + value, 0),
|
||||
);
|
||||
const tickStep = Math.ceil(
|
||||
(Math.max(...monthlyTotals) * 1.05) / Y_TICK_COUNT,
|
||||
);
|
||||
const topTick = tickStep * Y_TICK_COUNT;
|
||||
const yTicks = Array.from(
|
||||
{ length: Y_TICK_COUNT + 1 },
|
||||
(_, tickNumber) => tickNumber * tickStep,
|
||||
);
|
||||
const { i18n } = useLingui();
|
||||
const maxValue = Math.max(...months.map((month) => month.value));
|
||||
const { maxTick, ticks } = getNiceScale(maxValue);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Plot>
|
||||
<YAxis>
|
||||
{yTicks.toReversed().map((tick) => (
|
||||
{ticks.toReversed().map((tick) => (
|
||||
<YLabel key={tick}>{tick}</YLabel>
|
||||
))}
|
||||
</YAxis>
|
||||
<Bars>
|
||||
<BarsRow>
|
||||
{months.map((month, columnNumber) => {
|
||||
const total = monthlyTotals[columnNumber];
|
||||
return (
|
||||
<BarColumn key={month.label}>
|
||||
<Stack
|
||||
style={{
|
||||
height: `${(total / topTick) * 100}%`,
|
||||
transform: active ? 'scaleY(1)' : 'scaleY(0)',
|
||||
transitionDelay: active
|
||||
? `${columnNumber * 50}ms`
|
||||
: '0ms',
|
||||
}}
|
||||
>
|
||||
{stages.map((stage, stageNumber) => (
|
||||
<Segment
|
||||
key={stage.label}
|
||||
data-tone={stage.tone}
|
||||
style={{
|
||||
height: `${(month.values[stageNumber] / total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</BarColumn>
|
||||
);
|
||||
})}
|
||||
</BarsRow>
|
||||
<PlotArea>
|
||||
{ticks.map((tick) => (
|
||||
<GridLine
|
||||
key={tick}
|
||||
style={{ bottom: `${(tick / maxTick) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
<BarsRow>
|
||||
{months.map((month, columnNumber) => {
|
||||
const heightPercent = (month.value / maxTick) * 100;
|
||||
return (
|
||||
<BarColumn key={month.id}>
|
||||
<ValueLabel
|
||||
style={{
|
||||
bottom: `${heightPercent}%`,
|
||||
opacity: active ? 1 : 0,
|
||||
transitionDelay: active
|
||||
? `${300 + columnNumber * 50}ms`
|
||||
: '0ms',
|
||||
}}
|
||||
>
|
||||
{month.value}
|
||||
</ValueLabel>
|
||||
<Bar
|
||||
style={{
|
||||
height: `${heightPercent}%`,
|
||||
transform: active ? 'scaleY(1)' : 'scaleY(0)',
|
||||
transitionDelay: active
|
||||
? `${columnNumber * 50}ms`
|
||||
: '0ms',
|
||||
}}
|
||||
/>
|
||||
</BarColumn>
|
||||
);
|
||||
})}
|
||||
</BarsRow>
|
||||
</PlotArea>
|
||||
<XLabels>
|
||||
{months.map((month) => (
|
||||
<XLabel key={month.label}>{month.label}</XLabel>
|
||||
<XLabel key={month.id}>{i18n._(month.label)}</XLabel>
|
||||
))}
|
||||
</XLabels>
|
||||
</Bars>
|
||||
|
||||
+121
-60
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { THEME_LIGHT } from 'twenty-ui/theme';
|
||||
|
||||
import { previewFontSize } from '@/app-preview/preview-font-size';
|
||||
@@ -8,33 +12,41 @@ import { EASING } from '@/tokens';
|
||||
|
||||
import { type DashboardStage } from '../types/dashboard-stage';
|
||||
|
||||
const SIZE = 96;
|
||||
const STROKE = 16;
|
||||
const SIZE = 160;
|
||||
const STROKE = 18;
|
||||
const RADIUS = (SIZE - STROKE) / 2;
|
||||
const CENTER = SIZE / 2;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
const GAP = CIRCUMFERENCE * 0.008;
|
||||
const LEGEND_PAGE_SIZE = 3;
|
||||
|
||||
const Root = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const ChartArea = styled.div`
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1;
|
||||
max-height: 100%;
|
||||
max-width: ${SIZE}px;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Ring = styled.svg`
|
||||
display: block;
|
||||
height: 100%;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
opacity 0.5s ${EASING.standard},
|
||||
transform 0.5s ${EASING.standard};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Slice = styled.circle`
|
||||
@@ -46,17 +58,20 @@ const Slice = styled.circle`
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
&[data-tone='blue'] {
|
||||
stroke: ${THEME_LIGHT.color.blue8};
|
||||
&[data-tone='red'] {
|
||||
stroke: ${THEME_LIGHT.color.red8};
|
||||
}
|
||||
&[data-tone='purple'] {
|
||||
stroke: ${THEME_LIGHT.color.purple8};
|
||||
}
|
||||
&[data-tone='sky'] {
|
||||
stroke: ${THEME_LIGHT.color.sky8};
|
||||
}
|
||||
&[data-tone='turquoise'] {
|
||||
stroke: ${THEME_LIGHT.color.turquoise8};
|
||||
}
|
||||
&[data-tone='orange'] {
|
||||
stroke: ${THEME_LIGHT.color.orange8};
|
||||
&[data-tone='yellow'] {
|
||||
stroke: ${THEME_LIGHT.color.yellow8};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -78,113 +93,159 @@ const CenterValue = styled.span`
|
||||
`;
|
||||
|
||||
const CenterLabel = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.light};
|
||||
color: ${THEME_LIGHT.font.color.tertiary};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.xs)};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const Legend = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 48px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const LegendRow = styled.div`
|
||||
const Pager = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const LegendDot = styled.span`
|
||||
const PagerArrow = styled.span`
|
||||
align-items: center;
|
||||
color: ${THEME_LIGHT.font.color.light};
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${THEME_LIGHT.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const PagerText = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.light};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.sm)};
|
||||
font-variant-numeric: tabular-nums;
|
||||
`;
|
||||
|
||||
const Item = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 5px;
|
||||
`;
|
||||
|
||||
const ItemDot = styled.span`
|
||||
border-radius: ${THEME_LIGHT.border.radius.xs};
|
||||
flex-shrink: 0;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
height: 9px;
|
||||
width: 9px;
|
||||
|
||||
&[data-tone='blue'] {
|
||||
background-color: ${THEME_LIGHT.color.blue8};
|
||||
&[data-tone='red'] {
|
||||
background-color: ${THEME_LIGHT.color.red8};
|
||||
}
|
||||
&[data-tone='purple'] {
|
||||
background-color: ${THEME_LIGHT.color.purple8};
|
||||
}
|
||||
&[data-tone='sky'] {
|
||||
background-color: ${THEME_LIGHT.color.sky8};
|
||||
}
|
||||
&[data-tone='turquoise'] {
|
||||
background-color: ${THEME_LIGHT.color.turquoise8};
|
||||
}
|
||||
&[data-tone='orange'] {
|
||||
background-color: ${THEME_LIGHT.color.orange8};
|
||||
&[data-tone='yellow'] {
|
||||
background-color: ${THEME_LIGHT.color.yellow8};
|
||||
}
|
||||
`;
|
||||
|
||||
const LegendLabel = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.secondary};
|
||||
flex: 1;
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
|
||||
`;
|
||||
|
||||
const LegendValue = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.tertiary};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
|
||||
font-variant-numeric: tabular-nums;
|
||||
const ItemLabel = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.primary};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.sm)};
|
||||
font-weight: ${THEME_LIGHT.font.weight.medium};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export function DonutChart({
|
||||
active,
|
||||
stages,
|
||||
values,
|
||||
}: {
|
||||
active: boolean;
|
||||
stages: DashboardStage[];
|
||||
values: number[];
|
||||
}) {
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
const { i18n } = useLingui();
|
||||
const [page, setPage] = useState(0);
|
||||
const total = stages.reduce((sum, stage) => sum + stage.value, 0);
|
||||
const pageCount = Math.ceil(stages.length / LEGEND_PAGE_SIZE);
|
||||
const visibleStages = stages.slice(
|
||||
page * LEGEND_PAGE_SIZE,
|
||||
page * LEGEND_PAGE_SIZE + LEGEND_PAGE_SIZE,
|
||||
);
|
||||
|
||||
let cumulative = 0;
|
||||
const slices = stages.map((stage, stageNumber) => {
|
||||
const value = values[stageNumber];
|
||||
const arc = (value / total) * CIRCUMFERENCE;
|
||||
const startAngle = -90 + (cumulative / total) * 360;
|
||||
cumulative += value;
|
||||
return { arc, stage, startAngle, value };
|
||||
const slices = stages.map((stage) => {
|
||||
const arc = total > 0 ? (stage.value / total) * CIRCUMFERENCE : 0;
|
||||
const startAngle = -90 + (total > 0 ? cumulative / total : 0) * 360;
|
||||
cumulative += stage.value;
|
||||
return { arc, stage, startAngle };
|
||||
});
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<ChartArea>
|
||||
<Ring
|
||||
height={SIZE}
|
||||
style={{
|
||||
opacity: active ? 1 : 0,
|
||||
transform: active ? 'scale(1)' : 'scale(0.85)',
|
||||
}}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
width={SIZE}
|
||||
>
|
||||
{slices.map(({ arc, stage, startAngle }) => (
|
||||
<Slice
|
||||
key={stage.label}
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
data-tone={stage.tone}
|
||||
r={RADIUS}
|
||||
strokeDasharray={`${arc} ${CIRCUMFERENCE - arc}`}
|
||||
strokeWidth={STROKE}
|
||||
transform={`rotate(${startAngle} ${CENTER} ${CENTER})`}
|
||||
/>
|
||||
))}
|
||||
{slices.map(({ arc, stage, startAngle }) => {
|
||||
const visibleArc = stage.value > 0 ? Math.max(arc - GAP, 0.5) : 0;
|
||||
return (
|
||||
<Slice
|
||||
key={stage.id}
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
data-tone={stage.tone}
|
||||
r={RADIUS}
|
||||
strokeDasharray={`${visibleArc} ${CIRCUMFERENCE - visibleArc}`}
|
||||
strokeWidth={STROKE}
|
||||
transform={`rotate(${startAngle} ${CENTER} ${CENTER})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Ring>
|
||||
<Center>
|
||||
<CenterValue>{total}</CenterValue>
|
||||
<CenterLabel>Deals</CenterLabel>
|
||||
<CenterLabel>{i18n._(msg`Total`)}</CenterLabel>
|
||||
</Center>
|
||||
</ChartArea>
|
||||
<Legend>
|
||||
{slices.map(({ stage, value }) => (
|
||||
<LegendRow key={stage.label}>
|
||||
<LegendDot data-tone={stage.tone} />
|
||||
<LegendLabel>{stage.label}</LegendLabel>
|
||||
<LegendValue>{value}</LegendValue>
|
||||
</LegendRow>
|
||||
<Pager>
|
||||
<PagerArrow
|
||||
onClick={() =>
|
||||
setPage((current) => (current - 1 + pageCount) % pageCount)
|
||||
}
|
||||
>
|
||||
<IconChevronLeft size={14} stroke={1.8} />
|
||||
</PagerArrow>
|
||||
<PagerText>
|
||||
{page + 1}/{pageCount}
|
||||
</PagerText>
|
||||
<PagerArrow
|
||||
onClick={() => setPage((current) => (current + 1) % pageCount)}
|
||||
>
|
||||
<IconChevronRight size={14} stroke={1.8} />
|
||||
</PagerArrow>
|
||||
</Pager>
|
||||
{visibleStages.map((stage) => (
|
||||
<Item key={stage.id}>
|
||||
<ItemDot data-tone={stage.tone} />
|
||||
<ItemLabel>{i18n._(stage.label)}</ItemLabel>
|
||||
</Item>
|
||||
))}
|
||||
</Legend>
|
||||
</Root>
|
||||
|
||||
+10
-1
@@ -1,3 +1,4 @@
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconTrendingDown, IconTrendingUp } from '@tabler/icons-react';
|
||||
import { THEME_LIGHT } from 'twenty-ui/theme';
|
||||
@@ -15,6 +16,11 @@ const Body = styled.div`
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.secondary};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
|
||||
`;
|
||||
|
||||
const Value = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.primary};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.xxl)};
|
||||
@@ -42,13 +48,16 @@ const TrendIcon = styled.span`
|
||||
`;
|
||||
|
||||
const TrendPercent = styled.span`
|
||||
color: ${THEME_LIGHT.font.color.secondary};
|
||||
color: ${THEME_LIGHT.font.color.light};
|
||||
font-size: ${previewFontSize(THEME_LIGHT.font.size.xs)};
|
||||
`;
|
||||
|
||||
export function KpiCard({ kpi }: { kpi: DashboardKpi }) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return (
|
||||
<Body>
|
||||
<Label>{i18n._(kpi.label)}</Label>
|
||||
<Value>{kpi.value}</Value>
|
||||
<Trend>
|
||||
<TrendIcon data-direction={kpi.trendDirection}>
|
||||
|
||||
+20
-16
@@ -1,3 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { type DashboardKpi } from '../types/dashboard-kpi';
|
||||
import { type DashboardMonth } from '../types/dashboard-month';
|
||||
import { type DashboardStage } from '../types/dashboard-stage';
|
||||
@@ -6,39 +8,41 @@ export const DASHBOARD_VISUAL_DATA: {
|
||||
byMonth: DashboardMonth[];
|
||||
kpis: DashboardKpi[];
|
||||
stages: DashboardStage[];
|
||||
stageTotals: number[];
|
||||
} = {
|
||||
stages: [
|
||||
{ label: 'New', tone: 'blue' },
|
||||
{ label: 'Qualified', tone: 'purple' },
|
||||
{ label: 'Proposal', tone: 'turquoise' },
|
||||
{ label: 'Won', tone: 'orange' },
|
||||
{ id: 'new', label: msg`New`, tone: 'red', value: 47 },
|
||||
{ id: 'screening', label: msg`Screening`, tone: 'purple', value: 34 },
|
||||
{ id: 'meeting', label: msg`Meeting`, tone: 'sky', value: 27 },
|
||||
{ id: 'proposal', label: msg`Proposal`, tone: 'turquoise', value: 20 },
|
||||
{ id: 'customer', label: msg`Customer`, tone: 'yellow', value: 12 },
|
||||
],
|
||||
stageTotals: [47, 34, 27, 20],
|
||||
byMonth: [
|
||||
{ label: 'Jan', values: [7, 5, 4, 2] },
|
||||
{ label: 'Feb', values: [9, 6, 5, 3] },
|
||||
{ label: 'Mar', values: [8, 6, 4, 2] },
|
||||
{ label: 'Apr', values: [12, 8, 6, 4] },
|
||||
{ label: 'May', values: [15, 11, 8, 5] },
|
||||
{ label: 'Jun', values: [13, 9, 7, 4] },
|
||||
{ label: 'Jul', values: [17, 12, 9, 6] },
|
||||
{ id: 'jan', label: msg`Jan`, value: 12 },
|
||||
{ id: 'feb', label: msg`Feb`, value: 16 },
|
||||
{ id: 'mar', label: msg`Mar`, value: 14 },
|
||||
{ id: 'apr', label: msg`Apr`, value: 22 },
|
||||
{ id: 'may', label: msg`May`, value: 27 },
|
||||
{ id: 'jun', label: msg`Jun`, value: 24 },
|
||||
{ id: 'jul', label: msg`Jul`, value: 31 },
|
||||
],
|
||||
kpis: [
|
||||
{
|
||||
label: 'Revenue (YTD)',
|
||||
id: 'revenue',
|
||||
label: msg`Revenue (YTD)`,
|
||||
trendDirection: 'up',
|
||||
trendPercent: 12,
|
||||
value: '$1.2M',
|
||||
},
|
||||
{
|
||||
label: 'Avg deal size',
|
||||
id: 'avg-deal-size',
|
||||
label: msg`Avg deal size`,
|
||||
trendDirection: 'up',
|
||||
trendPercent: 5,
|
||||
value: '$9.4K',
|
||||
},
|
||||
{
|
||||
label: 'Win rate',
|
||||
id: 'win-rate',
|
||||
label: msg`Win rate`,
|
||||
trendDirection: 'down',
|
||||
trendPercent: 3,
|
||||
value: '34%',
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type DashboardKpi = {
|
||||
label: string;
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
trendDirection: 'down' | 'up';
|
||||
trendPercent: number;
|
||||
value: string;
|
||||
|
||||
+5
-2
@@ -1,4 +1,7 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type DashboardMonth = {
|
||||
label: string;
|
||||
values: number[];
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
value: number;
|
||||
};
|
||||
|
||||
+6
-1
@@ -1 +1,6 @@
|
||||
export type DashboardStageTone = 'blue' | 'orange' | 'purple' | 'turquoise';
|
||||
export type DashboardStageTone =
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'sky'
|
||||
| 'turquoise'
|
||||
| 'yellow';
|
||||
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type DashboardStageTone } from './dashboard-stage-tone';
|
||||
|
||||
export type DashboardStage = {
|
||||
label: string;
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
tone: DashboardStageTone;
|
||||
value: number;
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const SpotlightVisual = styled.div`
|
||||
border: 1px solid ${color('black-20')};
|
||||
border-radius: ${radius(2)};
|
||||
margin: ${spacing(4)};
|
||||
min-height: 260px;
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
|
||||
${mediaUp('md')} {
|
||||
|
||||
Reference in New Issue
Block a user