move sdk packages to its own folder and rename api & dashboard
This commit is contained in:
113
apps/dashboard/src/components/report/PreviousDiffIndicator.tsx
Normal file
113
apps/dashboard/src/components/report/PreviousDiffIndicator.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useChartContext } from './chart/ChartProvider';
|
||||
|
||||
export function getDiffIndicator<A, B, C>(
|
||||
inverted: boolean | undefined,
|
||||
state: string | undefined | null,
|
||||
positive: A,
|
||||
negative: B,
|
||||
neutral: C
|
||||
): A | B | C {
|
||||
if (state === 'neutral' || !state) {
|
||||
return neutral;
|
||||
}
|
||||
|
||||
if (inverted === true) {
|
||||
return state === 'positive' ? negative : positive;
|
||||
}
|
||||
return state === 'positive' ? positive : negative;
|
||||
}
|
||||
|
||||
// TODO: Fix this mess!
|
||||
|
||||
interface PreviousDiffIndicatorProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
children?: React.ReactNode;
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicator({
|
||||
diff,
|
||||
state,
|
||||
children,
|
||||
}: PreviousDiffIndicatorProps) {
|
||||
const { previous, previousIndicatorInverted } = useChartContext();
|
||||
const variant = getDiffIndicator(
|
||||
previousIndicatorInverted,
|
||||
state,
|
||||
'success',
|
||||
'destructive',
|
||||
undefined
|
||||
);
|
||||
const number = useNumber();
|
||||
|
||||
if (diff === null || diff === undefined || previous === false) {
|
||||
return children ?? null;
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (state === 'positive') {
|
||||
return <TrendingUpIcon size={15} />;
|
||||
}
|
||||
if (state === 'negative') {
|
||||
return <TrendingDownIcon size={15} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge className="flex gap-1" variant={variant}>
|
||||
{renderIcon()}
|
||||
{number.format(diff)}%
|
||||
</Badge>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicatorText({
|
||||
diff,
|
||||
state,
|
||||
className,
|
||||
}: PreviousDiffIndicatorProps & { className?: string }) {
|
||||
const { previous, previousIndicatorInverted } = useChartContext();
|
||||
const number = useNumber();
|
||||
if (diff === null || diff === undefined || previous === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (state === 'positive') {
|
||||
return <TrendingUpIcon size={15} />;
|
||||
}
|
||||
if (state === 'negative') {
|
||||
return <TrendingDownIcon size={15} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn([
|
||||
'flex gap-0.5 items-center',
|
||||
getDiffIndicator(
|
||||
previousIndicatorInverted,
|
||||
state,
|
||||
'text-emerald-600',
|
||||
'text-red-600',
|
||||
undefined
|
||||
),
|
||||
className,
|
||||
])}
|
||||
>
|
||||
{renderIcon()}
|
||||
{number.short(diff)}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/dashboard/src/components/report/ReportChartType.tsx
Normal file
32
apps/dashboard/src/components/report/ReportChartType.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { LineChartIcon } from 'lucide-react';
|
||||
|
||||
import { chartTypes } from '@mixan/constants';
|
||||
import { objectToZodEnums } from '@mixan/validation';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { changeChartType } from './reportSlice';
|
||||
|
||||
interface ReportChartTypeProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||
const dispatch = useDispatch();
|
||||
const type = useSelector((state) => state.report.chartType);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
icon={LineChartIcon}
|
||||
className={className}
|
||||
placeholder="Chart type"
|
||||
onChange={(value) => {
|
||||
dispatch(changeChartType(value));
|
||||
}}
|
||||
value={type}
|
||||
items={objectToZodEnums(chartTypes).map((key) => ({
|
||||
label: chartTypes[key],
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
apps/dashboard/src/components/report/ReportInterval.tsx
Normal file
66
apps/dashboard/src/components/report/ReportInterval.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { ClockIcon } from 'lucide-react';
|
||||
|
||||
import {
|
||||
isHourIntervalEnabledByRange,
|
||||
isMinuteIntervalEnabledByRange,
|
||||
} from '@mixan/constants';
|
||||
import type { IInterval } from '@mixan/validation';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { changeInterval } from './reportSlice';
|
||||
|
||||
interface ReportIntervalProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
if (
|
||||
chartType !== 'linear' &&
|
||||
chartType !== 'histogram' &&
|
||||
chartType !== 'area' &&
|
||||
chartType !== 'metric'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
icon={ClockIcon}
|
||||
className={className}
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value));
|
||||
}}
|
||||
value={interval}
|
||||
items={[
|
||||
{
|
||||
value: 'minute',
|
||||
label: 'Minute',
|
||||
disabled: !isMinuteIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'hour',
|
||||
label: 'Hour',
|
||||
disabled: !isHourIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'day',
|
||||
label: 'Day',
|
||||
},
|
||||
{
|
||||
value: 'month',
|
||||
label: 'Month',
|
||||
disabled:
|
||||
range === 'today' ||
|
||||
range === '24h' ||
|
||||
range === '1h' ||
|
||||
range === '30min',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
apps/dashboard/src/components/report/ReportLineType.tsx
Normal file
37
apps/dashboard/src/components/report/ReportLineType.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { Tv2Icon } from 'lucide-react';
|
||||
|
||||
import { lineTypes } from '@mixan/constants';
|
||||
import { objectToZodEnums } from '@mixan/validation';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { changeLineType } from './reportSlice';
|
||||
|
||||
interface ReportLineTypeProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportLineType({ className }: ReportLineTypeProps) {
|
||||
const dispatch = useDispatch();
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const type = useSelector((state) => state.report.lineType);
|
||||
|
||||
if (chartType != 'linear' && chartType != 'area') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
icon={Tv2Icon}
|
||||
className={className}
|
||||
placeholder="Line type"
|
||||
onChange={(value) => {
|
||||
dispatch(changeLineType(value));
|
||||
}}
|
||||
value={type}
|
||||
items={objectToZodEnums(lineTypes).map((key) => ({
|
||||
label: lineTypes[key],
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
102
apps/dashboard/src/components/report/ReportRange.tsx
Normal file
102
apps/dashboard/src/components/report/ReportRange.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { endOfDay, format, startOfDay } from 'date-fns';
|
||||
import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react';
|
||||
import type { SelectRangeEventHandler } from 'react-day-picker';
|
||||
|
||||
import { timeRanges } from '@mixan/constants';
|
||||
import type { IChartRange } from '@mixan/validation';
|
||||
|
||||
import type { ExtendedComboboxProps } from '../ui/combobox';
|
||||
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
|
||||
import { changeDates, changeEndDate, changeStartDate } from './reportSlice';
|
||||
|
||||
export function ReportRange({
|
||||
range,
|
||||
onRangeChange,
|
||||
onDatesChange,
|
||||
dates,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
range: IChartRange;
|
||||
onRangeChange: (range: IChartRange) => void;
|
||||
onDatesChange: SelectRangeEventHandler;
|
||||
dates: { startDate: string | null; endDate: string | null };
|
||||
} & Omit<ExtendedComboboxProps<string>, 'value' | 'onChange'>) {
|
||||
const { isBelowSm } = useBreakpoint('sm');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant={'outline'}
|
||||
className={cn('justify-start text-left font-normal', className)}
|
||||
icon={CalendarIcon}
|
||||
{...props}
|
||||
>
|
||||
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{dates.startDate ? (
|
||||
dates.endDate ? (
|
||||
<>
|
||||
{format(dates.startDate, 'LLL dd')} -{' '}
|
||||
{format(dates.endDate, 'LLL dd')}
|
||||
</>
|
||||
) : (
|
||||
format(dates.startDate, 'LLL dd, y')
|
||||
)
|
||||
) : (
|
||||
<span>{range}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-4 border-b border-border">
|
||||
<ToggleGroup
|
||||
value={range}
|
||||
onValueChange={(value) => {
|
||||
if (value) onRangeChange(value as IChartRange);
|
||||
}}
|
||||
type="single"
|
||||
variant="outline"
|
||||
className="flex-wrap max-sm:max-w-xs"
|
||||
>
|
||||
{Object.values(timeRanges).map((key) => (
|
||||
<ToggleGroupItem value={key} aria-label={key} key={key}>
|
||||
{key}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={
|
||||
dates.startDate ? new Date(dates.startDate) : new Date()
|
||||
}
|
||||
selected={{
|
||||
from: dates.startDate ? new Date(dates.startDate) : undefined,
|
||||
to: dates.endDate ? new Date(dates.endDate) : undefined,
|
||||
}}
|
||||
onSelect={onDatesChange}
|
||||
numberOfMonths={isBelowSm ? 1 : 2}
|
||||
className="[&_table]:mx-auto [&_table]:w-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
apps/dashboard/src/components/report/ReportSaveButton.tsx
Normal file
63
apps/dashboard/src/components/report/ReportSaveButton.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { resetDirty } from './reportSlice';
|
||||
|
||||
interface ReportSaveButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
||||
const { reportId } = useAppParams<{ reportId: string | undefined }>();
|
||||
const dispatch = useDispatch();
|
||||
const update = api.report.update.useMutation({
|
||||
onSuccess() {
|
||||
dispatch(resetDirty());
|
||||
toast('Success', {
|
||||
description: 'Report updated.',
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
const report = useSelector((state) => state.report);
|
||||
|
||||
if (reportId) {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
disabled={!report.dirty}
|
||||
loading={update.isLoading}
|
||||
onClick={() => {
|
||||
update.mutate({
|
||||
reportId: reportId,
|
||||
report,
|
||||
});
|
||||
}}
|
||||
icon={SaveIcon}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
disabled={!report.dirty}
|
||||
onClick={() => {
|
||||
pushModal('SaveReport', {
|
||||
report,
|
||||
});
|
||||
}}
|
||||
icon={SaveIcon}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
106
apps/dashboard/src/components/report/chart/Chart.tsx
Normal file
106
apps/dashboard/src/components/report/chart/Chart.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
|
||||
import type { IChartInput } from '@mixan/validation';
|
||||
|
||||
import { ChartEmpty } from './ChartEmpty';
|
||||
import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportHistogramChart } from './ReportHistogramChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
import { ReportMapChart } from './ReportMapChart';
|
||||
import { ReportMetricChart } from './ReportMetricChart';
|
||||
import { ReportPieChart } from './ReportPieChart';
|
||||
|
||||
export type ReportChartProps = IChartInput;
|
||||
|
||||
export function Chart({
|
||||
interval,
|
||||
events,
|
||||
breakdowns,
|
||||
chartType,
|
||||
name,
|
||||
range,
|
||||
lineType,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
}: ReportChartProps) {
|
||||
const [references] = api.reference.getChartReferences.useSuspenseQuery({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
});
|
||||
|
||||
const [data] = api.chart.chart.useSuspenseQuery(
|
||||
{
|
||||
// dont send lineType since it does not need to be sent
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
chartType,
|
||||
events,
|
||||
breakdowns,
|
||||
name,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
projectId,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (data.series.length === 0) {
|
||||
return <ChartEmpty />;
|
||||
}
|
||||
|
||||
if (chartType === 'map') {
|
||||
return <ReportMapChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'histogram') {
|
||||
return <ReportHistogramChart interval={interval} data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'bar') {
|
||||
return <ReportBarChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'metric') {
|
||||
return <ReportMetricChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'pie') {
|
||||
return <ReportPieChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'linear') {
|
||||
return (
|
||||
<ReportLineChart
|
||||
lineType={lineType}
|
||||
interval={interval}
|
||||
data={data}
|
||||
references={references}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'area') {
|
||||
return (
|
||||
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
|
||||
);
|
||||
}
|
||||
|
||||
return <p>Unknown chart type</p>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import airplane from '@/lottie/airplane.json';
|
||||
import ballon from '@/lottie/ballon.json';
|
||||
import noData from '@/lottie/no-data.json';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { LottieComponentProps } from 'lottie-react';
|
||||
import Lottie from 'lottie-react';
|
||||
|
||||
const animations = {
|
||||
airplane,
|
||||
ballon,
|
||||
noData,
|
||||
};
|
||||
type Animations = keyof typeof animations;
|
||||
|
||||
export const ChartAnimation = ({
|
||||
name,
|
||||
...props
|
||||
}: Omit<LottieComponentProps, 'animationData'> & {
|
||||
name: Animations;
|
||||
}) => <Lottie animationData={animations[name]} loop={true} {...props} />;
|
||||
|
||||
export const ChartAnimationContainer = (
|
||||
props: React.ButtonHTMLAttributes<HTMLDivElement>
|
||||
) => (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'border border-border rounded-md p-8 bg-white',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
31
apps/dashboard/src/components/report/chart/ChartEmpty.tsx
Normal file
31
apps/dashboard/src/components/report/chart/ChartEmpty.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCardEmpty } from './MetricCard';
|
||||
|
||||
export function ChartEmpty() {
|
||||
const { editMode, chartType } = useChartContext();
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<FullPageEmptyState title="No data">
|
||||
We could not find any data for selected events and filter.
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'metric') {
|
||||
return <MetricCardEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'aspect-video w-full max-h-[300px] min-h-[200px] flex justify-center items-center'
|
||||
}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/dashboard/src/components/report/chart/ChartLoading.tsx
Normal file
15
apps/dashboard/src/components/report/chart/ChartLoading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ChartLoadingProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ChartLoading({ className }: ChartLoadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'aspect-video w-full bg-slate-200 animate-pulse rounded max-h-[300px] min-h-[200px]',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
110
apps/dashboard/src/components/report/chart/ChartProvider.tsx
Normal file
110
apps/dashboard/src/components/report/chart/ChartProvider.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
Suspense,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { IChartSerie } from '@/server/api/routers/chart';
|
||||
|
||||
import type { IChartInput } from '@mixan/validation';
|
||||
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
import { MetricCardLoading } from './MetricCard';
|
||||
|
||||
export interface ChartContextType extends IChartInput {
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
onClick?: (item: IChartSerie) => void;
|
||||
}
|
||||
|
||||
type ChartProviderProps = {
|
||||
children: React.ReactNode;
|
||||
} & ChartContextType;
|
||||
|
||||
const ChartContext = createContext<ChartContextType | null>({
|
||||
events: [],
|
||||
breakdowns: [],
|
||||
chartType: 'linear',
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: '',
|
||||
range: '7d',
|
||||
metric: 'sum',
|
||||
previous: false,
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
export function ChartProvider({
|
||||
children,
|
||||
editMode,
|
||||
previous,
|
||||
hideID,
|
||||
...props
|
||||
}: ChartProviderProps) {
|
||||
return (
|
||||
<ChartContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
...props,
|
||||
editMode: editMode ?? false,
|
||||
previous: previous ?? false,
|
||||
hideID: hideID ?? false,
|
||||
}),
|
||||
[editMode, previous, hideID, props]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function withChartProivder<ComponentProps>(
|
||||
WrappedComponent: React.FC<ComponentProps>
|
||||
) {
|
||||
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return props.chartType === 'metric' ? (
|
||||
<MetricCardLoading />
|
||||
) : (
|
||||
<ChartLoading />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
props.chartType === 'metric' ? (
|
||||
<MetricCardLoading />
|
||||
) : (
|
||||
<ChartLoading />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChartProvider {...props}>
|
||||
<WrappedComponent {...props} />
|
||||
</ChartProvider>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
WithChartProvider.displayName = `WithChartProvider(${
|
||||
WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'
|
||||
})`;
|
||||
|
||||
return memo(WithChartProvider);
|
||||
}
|
||||
|
||||
export function useChartContext() {
|
||||
return useContext(ChartContext)!;
|
||||
}
|
||||
33
apps/dashboard/src/components/report/chart/LazyChart.tsx
Normal file
33
apps/dashboard/src/components/report/chart/LazyChart.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
import type { ReportChartProps } from '.';
|
||||
import { ChartSwitch } from '.';
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
import type { ChartContextType } from './ChartProvider';
|
||||
|
||||
export function LazyChart(props: ReportChartProps & ChartContextType) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const once = useRef(false);
|
||||
const { inViewport } = useInViewport(ref, undefined, {
|
||||
disconnectOnLeave: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewport) {
|
||||
once.current = true;
|
||||
}
|
||||
}, [inViewport]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{once.current || inViewport ? (
|
||||
<ChartSwitch {...props} editMode={false} />
|
||||
) : (
|
||||
<ChartLoading />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
apps/dashboard/src/components/report/chart/MetricCard.tsx
Normal file
124
apps/dashboard/src/components/report/chart/MetricCard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { theme } from '@/utils/theme';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
|
||||
import type { IChartMetric } from '@mixan/validation';
|
||||
|
||||
import {
|
||||
getDiffIndicator,
|
||||
PreviousDiffIndicatorText,
|
||||
} from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
interface MetricCardProps {
|
||||
serie: IChartData['series'][number];
|
||||
color?: string;
|
||||
metric: IChartMetric;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
serie,
|
||||
color: _color,
|
||||
metric,
|
||||
unit,
|
||||
}: MetricCardProps) {
|
||||
const { previousIndicatorInverted } = useChartContext();
|
||||
const number = useNumber();
|
||||
|
||||
const renderValue = (value: number, unitClassName?: string) => {
|
||||
if (unit === 'min') {
|
||||
return <>{fancyMinutes(value)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{number.short(value)}
|
||||
{unit && <span className={unitClassName}>{unit}</span>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const previous = serie.metrics.previous[metric];
|
||||
|
||||
const graphColors = getDiffIndicator(
|
||||
previousIndicatorInverted,
|
||||
previous?.state,
|
||||
'green',
|
||||
'red',
|
||||
'blue'
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative card p-4 overflow-hidden h-24"
|
||||
key={serie.name}
|
||||
>
|
||||
<div className="absolute inset-0 -left-1 -right-1 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50 rounded-md">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 4}
|
||||
data={serie.data}
|
||||
style={{ marginTop: (height / 4) * 3 }}
|
||||
>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="monotone"
|
||||
fill={`transparent`}
|
||||
fillOpacity={1}
|
||||
stroke={graphColors}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 font-medium text-left min-w-0">
|
||||
<ColorSquare>{serie.event.id}</ColorSquare>
|
||||
<span className="text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{serie.name || serie.event.displayName || serie.event.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}
|
||||
</div>
|
||||
<div className="flex justify-between items-end mt-2">
|
||||
<div className="text-2xl font-bold text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
|
||||
</div>
|
||||
<PreviousDiffIndicatorText
|
||||
{...serie.metrics.previous[metric]}
|
||||
className="font-medium text-xs mb-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricCardEmpty() {
|
||||
return (
|
||||
<div className="card p-4 h-24">
|
||||
<div className="flex items-center justify-center h-full text-slate-600">
|
||||
No data
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricCardLoading() {
|
||||
return (
|
||||
<div className="h-24 p-4 py-5 flex flex-col card">
|
||||
<div className="bg-slate-200 rounded animate-pulse h-4 w-1/2"></div>
|
||||
<div className="bg-slate-200 rounded animate-pulse h-6 w-1/5 mt-auto"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/dashboard/src/components/report/chart/ReportAreaChart.tsx
Normal file
120
apps/dashboard/src/components/report/chart/ReportAreaChart.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { IChartLineType, IInterval } from '@mixan/validation';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ReportAreaChartProps {
|
||||
data: IChartData;
|
||||
interval: IInterval;
|
||||
lineType: IChartLineType;
|
||||
}
|
||||
|
||||
export function ReportAreaChart({
|
||||
lineType,
|
||||
interval,
|
||||
data,
|
||||
}: ReportAreaChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const number = useNumber();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart width={width} height={height} data={rechartData}>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={(m: string) => formatDate(m)}
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
<YAxis
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
tickFormatter={number.short}
|
||||
/>
|
||||
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<React.Fragment key={serie.name}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.8}
|
||||
></stop>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
key={serie.name}
|
||||
type={lineType}
|
||||
isAnimationActive={true}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={color}
|
||||
fill={`url(#color${color})`}
|
||||
stackId={'1'}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
apps/dashboard/src/components/report/chart/ReportBarChart.tsx
Normal file
134
apps/dashboard/src/components/report/chart/ReportBarChart.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
import { NOT_SET_VALUE } from '@mixan/constants';
|
||||
|
||||
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { SerieIcon } from './SerieIcon';
|
||||
|
||||
interface ReportBarChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode, metric, onClick } = useChartContext();
|
||||
const number = useNumber();
|
||||
const series = useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, 10)),
|
||||
[data, editMode]
|
||||
);
|
||||
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-full text-xs -mx-2',
|
||||
editMode && 'text-base card p-4'
|
||||
)}
|
||||
>
|
||||
{series.map((serie, index) => {
|
||||
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
className={cn(
|
||||
'relative py-3 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 rounded overflow-hidden',
|
||||
'[&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm',
|
||||
isClickable && 'cursor-pointer hover:!bg-slate-100'
|
||||
)}
|
||||
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
|
||||
>
|
||||
<div className="flex-1 break-all flex items-center gap-2 font-medium">
|
||||
<SerieIcon name={serie.name} />
|
||||
{serie.name}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
|
||||
<PreviousDiffIndicatorText
|
||||
{...serie.metrics.previous[metric]}
|
||||
className="text-xs font-medium"
|
||||
/>
|
||||
{serie.metrics.previous[metric]?.value}
|
||||
<div className="font-bold">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
<Progress
|
||||
color={getChartColor(index)}
|
||||
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
|
||||
value={(serie.metrics.sum / maxCount) * 100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// return (
|
||||
// <Table
|
||||
// overflow={editMode}
|
||||
// className={cn('table-fixed', editMode ? '' : 'mini')}
|
||||
// >
|
||||
// <TableHeader>
|
||||
// {table.getHeaderGroups().map((headerGroup) => (
|
||||
// <TableRow key={headerGroup.id}>
|
||||
// {headerGroup.headers.map((header) => (
|
||||
// <TableHead
|
||||
// key={header.id}
|
||||
// {...{
|
||||
// colSpan: header.colSpan,
|
||||
// }}
|
||||
// >
|
||||
// <div
|
||||
// {...{
|
||||
// className: cn(
|
||||
// 'flex items-center gap-2',
|
||||
// header.column.getCanSort() && 'cursor-pointer select-none'
|
||||
// ),
|
||||
// onClick: header.column.getToggleSortingHandler(),
|
||||
// }}
|
||||
// >
|
||||
// {flexRender(
|
||||
// header.column.columnDef.header,
|
||||
// header.getContext()
|
||||
// )}
|
||||
// {{
|
||||
// asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
// desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
// }[header.column.getIsSorted() as string] ?? null}
|
||||
// </div>
|
||||
// </TableHead>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableHeader>
|
||||
// <TableBody>
|
||||
// {table.getRowModel().rows.map((row) => (
|
||||
// <TableRow
|
||||
// key={row.id}
|
||||
// {...(onClick
|
||||
// ? {
|
||||
// onClick() {
|
||||
// onClick(row.original);
|
||||
// },
|
||||
// className: 'cursor-pointer',
|
||||
// }
|
||||
// : {})}
|
||||
// >
|
||||
// {row.getVisibleCells().map((cell) => (
|
||||
// <TableCell key={cell.id}>
|
||||
// {flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
// </TableCell>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// );
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
|
||||
import type { IToolTipProps } from '@/types';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
const { unit, interval } = useChartContext();
|
||||
const getLabel = useMappings();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const number = useNumber();
|
||||
if (!active || !payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const limit = 3;
|
||||
const sorted = payload
|
||||
.slice(0)
|
||||
.filter((item) => !item.dataKey.includes(':prev:count'))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl min-w-[180px]">
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
const data = (
|
||||
item.dataKey.includes(':')
|
||||
? // @ts-expect-error
|
||||
payload[`${item.dataKey.split(':')[0]}:payload`]
|
||||
: payload
|
||||
) as IRechartPayloadItem;
|
||||
|
||||
return (
|
||||
<React.Fragment key={data.label}>
|
||||
{index === 0 && data.date && (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{formatDate(new Date(data.date))}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: data.color }}
|
||||
/>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
|
||||
{getLabel(data.label)}
|
||||
</div>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{number.formatWithUnit(data.count, unit)}</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<PreviousDiffIndicator {...data.previous}>
|
||||
{!!data.previous &&
|
||||
`(${number.formatWithUnit(data.previous.value, unit)})`}
|
||||
</PreviousDiffIndicator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
<div className="text-muted-foreground">and {hidden.length} more...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { getChartColor, theme } from '@/utils/theme';
|
||||
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import type { IInterval } from '@mixan/validation';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ReportHistogramChartProps {
|
||||
data: IChartData;
|
||||
interval: IInterval;
|
||||
}
|
||||
|
||||
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||
const bg = theme?.colors?.slate?.['200'] as string;
|
||||
return (
|
||||
<rect
|
||||
{...{ x, y, width, height, top, left, right, bottom }}
|
||||
rx="8"
|
||||
fill={bg}
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportHistogramChart({
|
||||
interval,
|
||||
data,
|
||||
}: ReportHistogramChartProps) {
|
||||
const { editMode, previous } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const number = useNumber();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer>
|
||||
{({ width, height }) => (
|
||||
<BarChart width={width} height={height} data={rechartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
|
||||
<XAxis
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
allowDecimals={false}
|
||||
domain={[0, data.metrics.max]}
|
||||
tickFormatter={number.short}
|
||||
/>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<React.Fragment key={serie.name}>
|
||||
{previous && (
|
||||
<Bar
|
||||
key={`${serie.name}:prev`}
|
||||
name={`${serie.name}:prev`}
|
||||
dataKey={`${serie.index}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.2}
|
||||
radius={8}
|
||||
/>
|
||||
)}
|
||||
<Bar
|
||||
key={serie.name}
|
||||
name={serie.name}
|
||||
dataKey={`${serie.index}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={8}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
apps/dashboard/src/components/report/chart/ReportLineChart.tsx
Normal file
133
apps/dashboard/src/components/report/chart/ReportLineChart.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { IServiceReference } from '@mixan/db';
|
||||
import type { IChartLineType, IInterval } from '@mixan/validation';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ReportLineChartProps {
|
||||
data: IChartData;
|
||||
references: IServiceReference[];
|
||||
interval: IInterval;
|
||||
lineType: IChartLineType;
|
||||
}
|
||||
|
||||
export function ReportLineChart({
|
||||
lineType,
|
||||
interval,
|
||||
data,
|
||||
references,
|
||||
}: ReportLineChartProps) {
|
||||
const { editMode, previous } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const number = useNumber();
|
||||
console.log(references.map((ref) => ref.createdAt.getTime()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer>
|
||||
{({ width, height }) => (
|
||||
<LineChart width={width} height={height} data={rechartData}>
|
||||
{references.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'#94a3b8'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<YAxis
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
tickFormatter={number.short}
|
||||
/>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="timestamp"
|
||||
scale="utc"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={(m: string) => formatDate(new Date(m))}
|
||||
type="number"
|
||||
tickLine={false}
|
||||
/>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<React.Fragment key={serie.name}>
|
||||
<Line
|
||||
type={lineType}
|
||||
key={serie.name}
|
||||
name={serie.name}
|
||||
isAnimationActive={true}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
/>
|
||||
{previous && (
|
||||
<Line
|
||||
type={lineType}
|
||||
key={`${serie.name}:prev`}
|
||||
name={`${serie.name}:prev`}
|
||||
isAnimationActive={true}
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeDasharray={'6 6'}
|
||||
dataKey={`${serie.index}:prev:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { theme } from '@/utils/theme';
|
||||
import WorldMap from 'react-svg-worldmap';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
interface ReportMapChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMapChart({ data }: ReportMapChartProps) {
|
||||
const { metric, unit } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, 100);
|
||||
|
||||
const mapData = useMemo(
|
||||
() =>
|
||||
series.map((s) => ({
|
||||
country: s.name.toLowerCase(),
|
||||
value: s.metrics[metric],
|
||||
})),
|
||||
[series, metric]
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<WorldMap
|
||||
size={width}
|
||||
data={mapData}
|
||||
color={theme.colors['chart-0']}
|
||||
borderColor={'#103A96'}
|
||||
value-suffix={unit}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCard } from './MetricCard';
|
||||
|
||||
interface ReportMetricChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
const { editMode, metric, unit } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-4',
|
||||
editMode && 'md:grid-cols-2 lg:grid-cols-3'
|
||||
)}
|
||||
>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={serie.name}
|
||||
serie={serie}
|
||||
metric={metric}
|
||||
unit={unit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/dashboard/src/components/report/chart/ReportPieChart.tsx
Normal file
129
apps/dashboard/src/components/report/chart/ReportPieChart.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
|
||||
interface ReportPieChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
|
||||
const pieData = series.map((serie) => ({
|
||||
id: serie.name,
|
||||
color: getChartColor(serie.index),
|
||||
index: serie.index,
|
||||
label: serie.name,
|
||||
count: serie.metrics.sum,
|
||||
percent: serie.metrics.sum / sum,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('max-sm:-mx-3', editMode && 'card p-4')}>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const height = Math.min(Math.max(width * 0.5625, 250), 400);
|
||||
return (
|
||||
<PieChart width={width} height={height}>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<Pie
|
||||
dataKey={'count'}
|
||||
data={pieData}
|
||||
innerRadius={height / 4}
|
||||
outerRadius={height / 2.5}
|
||||
isAnimationActive={true}
|
||||
label={renderLabel}
|
||||
>
|
||||
{pieData.map((item) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.id}
|
||||
strokeWidth={2}
|
||||
stroke={item.color}
|
||||
fill={item.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
fill,
|
||||
payload,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
midAngle: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
fill: string;
|
||||
payload: { label: string; percent: number };
|
||||
}) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = 25 + innerRadius + (outerRadius - innerRadius);
|
||||
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
|
||||
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const label = payload.label;
|
||||
const percent = round(payload.percent * 100, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={xProcent}
|
||||
y={yProcent}
|
||||
fill="white"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontWeight={700}
|
||||
pointerEvents={'none'}
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={fill}
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
>
|
||||
{truncate(label, 20)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
169
apps/dashboard/src/components/report/chart/ReportTable.tsx
Normal file
169
apps/dashboard/src/components/report/chart/ReportTable.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useSelector } from '@/redux';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
|
||||
interface ReportTableProps {
|
||||
data: IChartData;
|
||||
visibleSeries: IChartData['series'];
|
||||
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export function ReportTable({
|
||||
data,
|
||||
visibleSeries,
|
||||
setVisibleSeries,
|
||||
}: ReportTableProps) {
|
||||
const { setPage, paginate, page } = usePagination(50);
|
||||
const number = useNumber();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const getLabel = useMappings();
|
||||
|
||||
function handleChange(name: string, checked: boolean) {
|
||||
setVisibleSeries((prev) => {
|
||||
if (checked) {
|
||||
return [...prev, name];
|
||||
} else {
|
||||
return prev.filter((item) => item !== name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[200px_1fr] border border-border rounded-md overflow-hidden">
|
||||
<Table className="rounded-none border-t-0 border-l-0 border-b-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginate(data.series).map((serie, index) => {
|
||||
const checked = !!visibleSeries.find(
|
||||
(item) => item.name === serie.name
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={serie.name}>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange(serie.name, !!checked)
|
||||
}
|
||||
style={
|
||||
checked
|
||||
? {
|
||||
background: getChartColor(index),
|
||||
borderColor: getChartColor(index),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
checked={checked}
|
||||
/>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{getLabel(serie.name)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getLabel(serie.name)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-auto">
|
||||
<Table className="rounded-none border-none">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Average</TableHead>
|
||||
{data.series[0]?.data.map((serie) => (
|
||||
<TableHead
|
||||
key={serie.date.toString()}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{formatDate(serie.date)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginate(data.series).map((serie) => {
|
||||
return (
|
||||
<TableRow key={serie.name}>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{number.format(serie.metrics.sum)}
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous.sum}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{number.format(serie.metrics.average)}
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous.average}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{serie.data.map((item) => {
|
||||
return (
|
||||
<TableCell className="h-10" key={item.date.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{number.format(item.count)}
|
||||
<PreviousDiffIndicator {...item.previous} />
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row md:justify-between md:items-center">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Badge>Total: {number.format(data.metrics.sum)}</Badge>
|
||||
<Badge>Average: {number.format(data.metrics.average)}</Badge>
|
||||
<Badge>Min: {number.format(data.metrics.min)}</Badge>
|
||||
<Badge>Max: {number.format(data.metrics.max)}</Badge>
|
||||
</div>
|
||||
<Pagination cursor={page} setCursor={setPage} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
interface ResponsiveContainerProps {
|
||||
children: (props: { width: number; height: number }) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const maxHeight = 300;
|
||||
const minHeight = 200;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxHeight,
|
||||
minHeight,
|
||||
}}
|
||||
className={cn('max-sm:-mx-3 aspect-video w-full', editMode && 'card p-4')}
|
||||
>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) =>
|
||||
children({
|
||||
width,
|
||||
height: Math.min(
|
||||
Math.max(width * 0.5625, minHeight),
|
||||
// we add p-4 (16px) padding in edit mode
|
||||
editMode ? maxHeight - 16 : maxHeight
|
||||
),
|
||||
})
|
||||
}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
apps/dashboard/src/components/report/chart/SerieIcon.tsx
Normal file
241
apps/dashboard/src/components/report/chart/SerieIcon.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { LucideIcon, LucideProps } from 'lucide-react';
|
||||
import {
|
||||
ActivityIcon,
|
||||
ExternalLinkIcon,
|
||||
HelpCircleIcon,
|
||||
MailIcon,
|
||||
MonitorIcon,
|
||||
MonitorPlayIcon,
|
||||
PodcastIcon,
|
||||
ScanIcon,
|
||||
SearchIcon,
|
||||
SmartphoneIcon,
|
||||
TabletIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@mixan/constants';
|
||||
|
||||
interface SerieIconProps extends LucideProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function getProxyImage(url: string) {
|
||||
return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
const createImageIcon = (url: string) => {
|
||||
return function (props: LucideProps) {
|
||||
return <img className="w-4 h-4 object-cover rounded" src={url} />;
|
||||
} as LucideIcon;
|
||||
};
|
||||
|
||||
const createFlagIcon = (url: string) => {
|
||||
return function (props: LucideProps) {
|
||||
return (
|
||||
<span className={`rounded !block !leading-[1rem] fi fi-${url}`}></span>
|
||||
);
|
||||
} as LucideIcon;
|
||||
};
|
||||
|
||||
const mapper: Record<string, LucideIcon> = {
|
||||
// Events
|
||||
screen_view: MonitorPlayIcon,
|
||||
session_start: ActivityIcon,
|
||||
session_end: ActivityIcon,
|
||||
link_out: ExternalLinkIcon,
|
||||
|
||||
// Websites
|
||||
linkedin: createImageIcon(getProxyImage('https://linkedin.com')),
|
||||
slack: createImageIcon(getProxyImage('https://slack.com')),
|
||||
pinterest: createImageIcon(getProxyImage('https://www.pinterest.se')),
|
||||
ecosia: createImageIcon(getProxyImage('https://ecosia.com')),
|
||||
yandex: createImageIcon(getProxyImage('https://yandex.com')),
|
||||
google: createImageIcon(getProxyImage('https://google.com')),
|
||||
facebook: createImageIcon(getProxyImage('https://facebook.com')),
|
||||
bing: createImageIcon(getProxyImage('https://bing.com')),
|
||||
twitter: createImageIcon(getProxyImage('https://x.com')),
|
||||
duckduckgo: createImageIcon(getProxyImage('https://duckduckgo.com')),
|
||||
'yahoo!': createImageIcon(getProxyImage('https://yahoo.com')),
|
||||
instagram: createImageIcon(getProxyImage('https://instagram.com')),
|
||||
gmail: createImageIcon(getProxyImage('https://mail.google.com/')),
|
||||
|
||||
'mobile safari': createImageIcon(
|
||||
getProxyImage(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
|
||||
)
|
||||
),
|
||||
chrome: createImageIcon(
|
||||
getProxyImage(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg'
|
||||
)
|
||||
),
|
||||
'samsung internet': createImageIcon(
|
||||
getProxyImage(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png'
|
||||
)
|
||||
),
|
||||
safari: createImageIcon(
|
||||
getProxyImage(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
|
||||
)
|
||||
),
|
||||
edge: createImageIcon(
|
||||
getProxyImage(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png'
|
||||
)
|
||||
),
|
||||
firefox: createImageIcon(
|
||||
getProxyImage(
|
||||
'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png'
|
||||
)
|
||||
),
|
||||
snapchat: createImageIcon(getProxyImage('https://snapchat.com')),
|
||||
|
||||
// Misc
|
||||
mobile: SmartphoneIcon,
|
||||
desktop: MonitorIcon,
|
||||
tablet: TabletIcon,
|
||||
search: SearchIcon,
|
||||
social: PodcastIcon,
|
||||
email: MailIcon,
|
||||
unknown: HelpCircleIcon,
|
||||
[NOT_SET_VALUE]: ScanIcon,
|
||||
|
||||
// Flags
|
||||
se: createFlagIcon('se'),
|
||||
us: createFlagIcon('us'),
|
||||
gb: createFlagIcon('gb'),
|
||||
ua: createFlagIcon('ua'),
|
||||
ru: createFlagIcon('ru'),
|
||||
de: createFlagIcon('de'),
|
||||
fr: createFlagIcon('fr'),
|
||||
br: createFlagIcon('br'),
|
||||
in: createFlagIcon('in'),
|
||||
it: createFlagIcon('it'),
|
||||
es: createFlagIcon('es'),
|
||||
pl: createFlagIcon('pl'),
|
||||
nl: createFlagIcon('nl'),
|
||||
id: createFlagIcon('id'),
|
||||
tr: createFlagIcon('tr'),
|
||||
ph: createFlagIcon('ph'),
|
||||
ca: createFlagIcon('ca'),
|
||||
ar: createFlagIcon('ar'),
|
||||
mx: createFlagIcon('mx'),
|
||||
za: createFlagIcon('za'),
|
||||
au: createFlagIcon('au'),
|
||||
co: createFlagIcon('co'),
|
||||
ch: createFlagIcon('ch'),
|
||||
at: createFlagIcon('at'),
|
||||
be: createFlagIcon('be'),
|
||||
pt: createFlagIcon('pt'),
|
||||
my: createFlagIcon('my'),
|
||||
th: createFlagIcon('th'),
|
||||
vn: createFlagIcon('vn'),
|
||||
sg: createFlagIcon('sg'),
|
||||
eg: createFlagIcon('eg'),
|
||||
sa: createFlagIcon('sa'),
|
||||
pk: createFlagIcon('pk'),
|
||||
bd: createFlagIcon('bd'),
|
||||
ro: createFlagIcon('ro'),
|
||||
hu: createFlagIcon('hu'),
|
||||
cz: createFlagIcon('cz'),
|
||||
gr: createFlagIcon('gr'),
|
||||
il: createFlagIcon('il'),
|
||||
no: createFlagIcon('no'),
|
||||
fi: createFlagIcon('fi'),
|
||||
dk: createFlagIcon('dk'),
|
||||
sk: createFlagIcon('sk'),
|
||||
bg: createFlagIcon('bg'),
|
||||
hr: createFlagIcon('hr'),
|
||||
rs: createFlagIcon('rs'),
|
||||
ba: createFlagIcon('ba'),
|
||||
si: createFlagIcon('si'),
|
||||
lv: createFlagIcon('lv'),
|
||||
lt: createFlagIcon('lt'),
|
||||
ee: createFlagIcon('ee'),
|
||||
by: createFlagIcon('by'),
|
||||
md: createFlagIcon('md'),
|
||||
kz: createFlagIcon('kz'),
|
||||
uz: createFlagIcon('uz'),
|
||||
kg: createFlagIcon('kg'),
|
||||
tj: createFlagIcon('tj'),
|
||||
tm: createFlagIcon('tm'),
|
||||
az: createFlagIcon('az'),
|
||||
ge: createFlagIcon('ge'),
|
||||
am: createFlagIcon('am'),
|
||||
af: createFlagIcon('af'),
|
||||
ir: createFlagIcon('ir'),
|
||||
iq: createFlagIcon('iq'),
|
||||
sy: createFlagIcon('sy'),
|
||||
lb: createFlagIcon('lb'),
|
||||
jo: createFlagIcon('jo'),
|
||||
ps: createFlagIcon('ps'),
|
||||
kw: createFlagIcon('kw'),
|
||||
qa: createFlagIcon('qa'),
|
||||
om: createFlagIcon('om'),
|
||||
ye: createFlagIcon('ye'),
|
||||
ae: createFlagIcon('ae'),
|
||||
bh: createFlagIcon('bh'),
|
||||
cy: createFlagIcon('cy'),
|
||||
mt: createFlagIcon('mt'),
|
||||
sm: createFlagIcon('sm'),
|
||||
li: createFlagIcon('li'),
|
||||
is: createFlagIcon('is'),
|
||||
al: createFlagIcon('al'),
|
||||
mk: createFlagIcon('mk'),
|
||||
me: createFlagIcon('me'),
|
||||
ad: createFlagIcon('ad'),
|
||||
lu: createFlagIcon('lu'),
|
||||
mc: createFlagIcon('mc'),
|
||||
fo: createFlagIcon('fo'),
|
||||
gg: createFlagIcon('gg'),
|
||||
je: createFlagIcon('je'),
|
||||
im: createFlagIcon('im'),
|
||||
gi: createFlagIcon('gi'),
|
||||
va: createFlagIcon('va'),
|
||||
ax: createFlagIcon('ax'),
|
||||
bl: createFlagIcon('bl'),
|
||||
mf: createFlagIcon('mf'),
|
||||
pm: createFlagIcon('pm'),
|
||||
yt: createFlagIcon('yt'),
|
||||
wf: createFlagIcon('wf'),
|
||||
tf: createFlagIcon('tf'),
|
||||
re: createFlagIcon('re'),
|
||||
sc: createFlagIcon('sc'),
|
||||
mu: createFlagIcon('mu'),
|
||||
zw: createFlagIcon('zw'),
|
||||
mz: createFlagIcon('mz'),
|
||||
na: createFlagIcon('na'),
|
||||
bw: createFlagIcon('bw'),
|
||||
ls: createFlagIcon('ls'),
|
||||
sz: createFlagIcon('sz'),
|
||||
bi: createFlagIcon('bi'),
|
||||
rw: createFlagIcon('rw'),
|
||||
ug: createFlagIcon('ug'),
|
||||
ke: createFlagIcon('ke'),
|
||||
tz: createFlagIcon('tz'),
|
||||
mg: createFlagIcon('mg'),
|
||||
};
|
||||
|
||||
export function SerieIcon({ name, ...props }: SerieIconProps) {
|
||||
const Icon = useMemo(() => {
|
||||
const mapped = mapper[name.toLowerCase()] ?? null;
|
||||
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (name.includes('http')) {
|
||||
return createImageIcon(getProxyImage(name));
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [name]);
|
||||
|
||||
return Icon ? (
|
||||
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
|
||||
<Icon size={16} {...props} />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
11
apps/dashboard/src/components/report/chart/chart-utils.ts
Normal file
11
apps/dashboard/src/components/report/chart/chart-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const formatter = new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
});
|
||||
|
||||
export function getYAxisWidth(value: number) {
|
||||
if (!isFinite(value)) {
|
||||
return 7.8 + 7.8;
|
||||
}
|
||||
|
||||
return formatter.format(value).toString().length * 7.8 + 7.8;
|
||||
}
|
||||
52
apps/dashboard/src/components/report/chart/index.tsx
Normal file
52
apps/dashboard/src/components/report/chart/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartInput } from '@mixan/validation';
|
||||
|
||||
import { Funnel } from '../funnel';
|
||||
import { Chart } from './Chart';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
|
||||
export type ReportChartProps = IChartInput;
|
||||
|
||||
export const ChartSwitch = withChartProivder(function ChartSwitch(
|
||||
props: ReportChartProps
|
||||
) {
|
||||
if (props.chartType === 'funnel') {
|
||||
return <Funnel {...props} />;
|
||||
}
|
||||
|
||||
return <Chart {...props} />;
|
||||
});
|
||||
|
||||
interface ChartSwitchShortcutProps {
|
||||
projectId: ReportChartProps['projectId'];
|
||||
range?: ReportChartProps['range'];
|
||||
previous?: ReportChartProps['previous'];
|
||||
chartType?: ReportChartProps['chartType'];
|
||||
interval?: ReportChartProps['interval'];
|
||||
events: ReportChartProps['events'];
|
||||
}
|
||||
|
||||
export const ChartSwitchShortcut = ({
|
||||
projectId,
|
||||
range = '7d',
|
||||
previous = false,
|
||||
chartType = 'linear',
|
||||
interval = 'day',
|
||||
events,
|
||||
}: ChartSwitchShortcutProps) => {
|
||||
return (
|
||||
<ChartSwitch
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
breakdowns={[]}
|
||||
previous={previous}
|
||||
chartType={chartType}
|
||||
interval={interval}
|
||||
name="Random"
|
||||
lineType="bump"
|
||||
metric="sum"
|
||||
events={events}
|
||||
/>
|
||||
);
|
||||
};
|
||||
175
apps/dashboard/src/components/report/funnel/Funnel.tsx
Normal file
175
apps/dashboard/src/components/report/funnel/Funnel.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { ArrowRight, ArrowRightIcon } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
|
||||
function FunnelChart({ from, to }: { from: number; to: number }) {
|
||||
const fromY = 100 - from;
|
||||
const toY = 100 - to;
|
||||
const steps = [
|
||||
`M0,${fromY}`,
|
||||
'L0,100',
|
||||
'L100,100',
|
||||
`L100,${toY}`,
|
||||
`L0,${fromY}`,
|
||||
];
|
||||
return (
|
||||
<svg viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="blue"
|
||||
x1="50"
|
||||
y1="100"
|
||||
x2="50"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* bottom */}
|
||||
<stop offset="0%" stop-color="#2564eb" />
|
||||
{/* top */}
|
||||
<stop offset="100%" stop-color="#2564eb" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="red"
|
||||
x1="50"
|
||||
y1="100"
|
||||
x2="50"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* bottom */}
|
||||
<stop offset="0%" stop-color="#f87171" />
|
||||
{/* top */}
|
||||
<stop offset="100%" stop-color="#fca5a5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y={fromY}
|
||||
width="100"
|
||||
height="100"
|
||||
fill="url(#red)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
<path d={steps.join(' ')} fill="url(#blue)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getDropoffColor(value: number) {
|
||||
if (value > 80) {
|
||||
return 'text-red-600';
|
||||
}
|
||||
if (value > 50) {
|
||||
return 'text-orange-600';
|
||||
}
|
||||
if (value > 30) {
|
||||
return 'text-yellow-600';
|
||||
}
|
||||
return 'text-green-600';
|
||||
}
|
||||
|
||||
export function FunnelSteps({
|
||||
steps,
|
||||
totalSessions,
|
||||
}: RouterOutputs['chart']['funnel']) {
|
||||
const { editMode } = useChartContext();
|
||||
return (
|
||||
<Carousel className="w-full" opts={{ loop: false, dragFree: true }}>
|
||||
<CarouselContent>
|
||||
<CarouselItem className={'flex-[0_0_0] pl-3'} />
|
||||
{steps.map((step, index, list) => {
|
||||
const finalStep = index === list.length - 1;
|
||||
return (
|
||||
<CarouselItem
|
||||
className={cn(
|
||||
'flex-[0_0_250px] max-w-full p-0 px-1',
|
||||
editMode && 'flex-[0_0_320px]'
|
||||
)}
|
||||
key={step.event.id}
|
||||
>
|
||||
<div className="card divide-y divide-border bg-white">
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground">Step {index + 1}</p>
|
||||
<h3 className="font-bold">
|
||||
{step.event.displayName || step.event.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="aspect-square relative">
|
||||
<FunnelChart from={step.prevPercent} to={step.percent} />
|
||||
<div className="absolute top-0 left-0 right-0 p-4 flex flex-col bg-white/40">
|
||||
<div className="uppercase font-medium text-muted-foreground">
|
||||
Sessions
|
||||
</div>
|
||||
<div className="uppercase text-3xl font-bold flex items-center">
|
||||
<span className="text-muted-foreground">
|
||||
{step.before}
|
||||
</span>
|
||||
<ArrowRightIcon size={16} className="mx-2" />
|
||||
<span>{step.current}</span>
|
||||
</div>
|
||||
{index !== 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground">
|
||||
{step.current} of {totalSessions} (
|
||||
{round(step.percent, 1)}%)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{finalStep ? (
|
||||
<div className={cn('p-4 flex flex-col items-center')}>
|
||||
<div className="uppercase text-xs font-medium">
|
||||
Conversion
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'uppercase text-3xl font-bold',
|
||||
getDropoffColor(step.dropoff.percent)
|
||||
)}
|
||||
>
|
||||
{round(step.percent, 1)}%
|
||||
</div>
|
||||
<div className="uppercase text-sm mt-0 font-medium text-muted-foreground">
|
||||
Converted {step.current} of {totalSessions} sessions
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('p-4 flex flex-col items-center')}>
|
||||
<div className="uppercase text-xs font-medium">Dropoff</div>
|
||||
<div
|
||||
className={cn(
|
||||
'uppercase text-3xl font-bold',
|
||||
getDropoffColor(step.dropoff.percent)
|
||||
)}
|
||||
>
|
||||
{round(step.dropoff.percent, 1)}%
|
||||
</div>
|
||||
<div className="uppercase text-sm mt-0 font-medium text-muted-foreground">
|
||||
Lost {step.dropoff.count} sessions
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
);
|
||||
}
|
||||
53
apps/dashboard/src/components/report/funnel/index.tsx
Normal file
53
apps/dashboard/src/components/report/funnel/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
|
||||
import type { IChartInput } from '@mixan/validation';
|
||||
|
||||
import { ChartEmpty } from '../chart/ChartEmpty';
|
||||
import { withChartProivder } from '../chart/ChartProvider';
|
||||
import { FunnelSteps } from './Funnel';
|
||||
|
||||
export type ReportChartProps = IChartInput & {
|
||||
initialData?: RouterOutputs['chart']['funnel'];
|
||||
};
|
||||
|
||||
export const Funnel = withChartProivder(function Chart({
|
||||
events,
|
||||
name,
|
||||
range,
|
||||
projectId,
|
||||
}: ReportChartProps) {
|
||||
const [data] = api.chart.funnel.useSuspenseQuery(
|
||||
{
|
||||
events,
|
||||
name,
|
||||
range,
|
||||
projectId,
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
chartType: 'funnel',
|
||||
breakdowns: [],
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
previous: false,
|
||||
formula: undefined,
|
||||
unit: undefined,
|
||||
metric: 'sum',
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (data.steps.length === 0) {
|
||||
return <ChartEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-mx-4">
|
||||
<FunnelSteps {...data} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
273
apps/dashboard/src/components/report/reportSlice.ts
Normal file
273
apps/dashboard/src/components/report/reportSlice.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { start } from 'repl';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { isSameDay, isSameMonth } from 'date-fns';
|
||||
|
||||
import {
|
||||
alphabetIds,
|
||||
getDefaultIntervalByDates,
|
||||
getDefaultIntervalByRange,
|
||||
isHourIntervalEnabledByRange,
|
||||
isMinuteIntervalEnabledByRange,
|
||||
} from '@mixan/constants';
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartInput,
|
||||
IChartLineType,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
} from '@mixan/validation';
|
||||
|
||||
type InitialState = IChartInput & {
|
||||
dirty: boolean;
|
||||
ready: boolean;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
};
|
||||
|
||||
// First approach: define the initial state using that type
|
||||
const initialState: InitialState = {
|
||||
ready: false,
|
||||
dirty: false,
|
||||
// TODO: remove this
|
||||
projectId: '',
|
||||
name: 'Untitled',
|
||||
chartType: 'linear',
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
breakdowns: [],
|
||||
events: [],
|
||||
range: '1m',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
previous: false,
|
||||
formula: undefined,
|
||||
unit: undefined,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
name: 'report',
|
||||
initialState,
|
||||
reducers: {
|
||||
resetDirty(state) {
|
||||
return {
|
||||
...state,
|
||||
dirty: false,
|
||||
};
|
||||
},
|
||||
reset() {
|
||||
return initialState;
|
||||
},
|
||||
ready() {
|
||||
return {
|
||||
...initialState,
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
setReport(state, action: PayloadAction<IChartInput>) {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
dirty: false,
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
setName(state, action: PayloadAction<string>) {
|
||||
state.dirty = true;
|
||||
state.name = action.payload;
|
||||
},
|
||||
// Events
|
||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
id: alphabetIds[state.events.length]!,
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
removeEvent: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
id: string;
|
||||
}>
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.filter(
|
||||
(event) => event.id !== action.payload.id
|
||||
);
|
||||
},
|
||||
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.map((event) => {
|
||||
if (event.id === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
return event;
|
||||
});
|
||||
},
|
||||
|
||||
// Previous
|
||||
changePrevious: (state, action: PayloadAction<boolean>) => {
|
||||
state.dirty = true;
|
||||
state.previous = action.payload;
|
||||
},
|
||||
|
||||
// Breakdowns
|
||||
addBreakdown: (
|
||||
state,
|
||||
action: PayloadAction<Omit<IChartBreakdown, 'id'>>
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.breakdowns.push({
|
||||
id: alphabetIds[state.breakdowns.length]!,
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
removeBreakdown: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
id: string;
|
||||
}>
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.breakdowns = state.breakdowns.filter(
|
||||
(event) => event.id !== action.payload.id
|
||||
);
|
||||
},
|
||||
changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => {
|
||||
state.dirty = true;
|
||||
state.breakdowns = state.breakdowns.map((breakdown) => {
|
||||
if (breakdown.id === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
return breakdown;
|
||||
});
|
||||
},
|
||||
|
||||
// Interval
|
||||
changeInterval: (state, action: PayloadAction<IInterval>) => {
|
||||
state.dirty = true;
|
||||
state.interval = action.payload;
|
||||
},
|
||||
|
||||
// Chart type
|
||||
changeChartType: (state, action: PayloadAction<IChartType>) => {
|
||||
state.dirty = true;
|
||||
state.chartType = action.payload;
|
||||
|
||||
if (
|
||||
!isMinuteIntervalEnabledByRange(state.range) &&
|
||||
state.interval === 'minute'
|
||||
) {
|
||||
state.interval = 'hour';
|
||||
}
|
||||
|
||||
if (
|
||||
!isHourIntervalEnabledByRange(state.range) &&
|
||||
state.interval === 'hour'
|
||||
) {
|
||||
state.interval = 'day';
|
||||
}
|
||||
},
|
||||
|
||||
// Line type
|
||||
changeLineType: (state, action: PayloadAction<IChartLineType>) => {
|
||||
state.dirty = true;
|
||||
state.lineType = action.payload;
|
||||
},
|
||||
|
||||
// Custom start and end date
|
||||
changeDates: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}>
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.startDate = action.payload.startDate;
|
||||
state.endDate = action.payload.endDate;
|
||||
|
||||
if (isSameDay(state.startDate, state.endDate)) {
|
||||
state.interval = 'hour';
|
||||
} else if (isSameMonth(state.startDate, state.endDate)) {
|
||||
state.interval = 'day';
|
||||
} else {
|
||||
state.interval = 'month';
|
||||
}
|
||||
},
|
||||
|
||||
// Date range
|
||||
changeStartDate: (state, action: PayloadAction<string>) => {
|
||||
state.dirty = true;
|
||||
state.startDate = action.payload;
|
||||
|
||||
const interval = getDefaultIntervalByDates(
|
||||
state.startDate,
|
||||
state.endDate
|
||||
);
|
||||
if (interval) {
|
||||
state.interval = interval;
|
||||
}
|
||||
},
|
||||
|
||||
// Date range
|
||||
changeEndDate: (state, action: PayloadAction<string>) => {
|
||||
state.dirty = true;
|
||||
state.endDate = action.payload;
|
||||
|
||||
const interval = getDefaultIntervalByDates(
|
||||
state.startDate,
|
||||
state.endDate
|
||||
);
|
||||
if (interval) {
|
||||
state.interval = interval;
|
||||
}
|
||||
},
|
||||
|
||||
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
|
||||
state.dirty = true;
|
||||
state.range = action.payload;
|
||||
state.startDate = null;
|
||||
state.endDate = null;
|
||||
|
||||
state.interval = getDefaultIntervalByRange(action.payload);
|
||||
},
|
||||
|
||||
// Formula
|
||||
changeFormula: (state, action: PayloadAction<string>) => {
|
||||
state.dirty = true;
|
||||
state.formula = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const {
|
||||
reset,
|
||||
ready,
|
||||
setReport,
|
||||
setName,
|
||||
addEvent,
|
||||
removeEvent,
|
||||
changeEvent,
|
||||
addBreakdown,
|
||||
removeBreakdown,
|
||||
changeBreakdown,
|
||||
changeInterval,
|
||||
changeDates,
|
||||
changeStartDate,
|
||||
changeEndDate,
|
||||
changeDateRanges,
|
||||
changeChartType,
|
||||
changeLineType,
|
||||
resetDirty,
|
||||
changeFormula,
|
||||
changePrevious,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DatabaseIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartEvent } from '@mixan/validation';
|
||||
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
interface EventPropertiesComboboxProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
export function EventPropertiesCombobox({
|
||||
event,
|
||||
}: EventPropertiesComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
}
|
||||
);
|
||||
|
||||
const properties = (query.data ?? []).map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
searchable
|
||||
placeholder="Select a filter"
|
||||
value=""
|
||||
items={properties}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
property: value,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs',
|
||||
!event.property && 'border-destructive text-destructive'
|
||||
)}
|
||||
>
|
||||
<DatabaseIcon size={12} />{' '}
|
||||
{event.property ? `Property: ${event.property}` : 'Select property'}
|
||||
</button>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Trash } from 'lucide-react';
|
||||
|
||||
export interface ReportBreakdownMoreProps {
|
||||
onClick: (action: 'remove') => void;
|
||||
}
|
||||
|
||||
export function ReportBreakdownMore({ onClick }: ReportBreakdownMoreProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => onClick('remove')}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { SplitIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartBreakdown } from '@mixan/validation';
|
||||
|
||||
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
|
||||
import { ReportBreakdownMore } from './ReportBreakdownMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportBreakdowns() {
|
||||
const { projectId } = useAppParams();
|
||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery({
|
||||
projectId,
|
||||
});
|
||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
||||
value: item,
|
||||
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
||||
}));
|
||||
|
||||
const handleMore = (breakdown: IChartBreakdown) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(removeBreakdown(breakdown));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Breakdown</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedBreakdowns.map((item, index) => {
|
||||
return (
|
||||
<div key={item.name} className="rounded-lg border bg-slate-50">
|
||||
<div className="flex items-center gap-2 p-2 px-4">
|
||||
<ColorSquare>{index}</ColorSquare>
|
||||
<Combobox
|
||||
icon={SplitIcon}
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={item.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeBreakdown({
|
||||
...item,
|
||||
name: value,
|
||||
})
|
||||
);
|
||||
}}
|
||||
items={propertiesCombobox}
|
||||
placeholder="Select..."
|
||||
/>
|
||||
<ReportBreakdownMore onClick={handleMore(item)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedBreakdowns.length === 0 && (
|
||||
<Combobox
|
||||
icon={SplitIcon}
|
||||
searchable
|
||||
value={''}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
addBreakdown({
|
||||
name: value,
|
||||
})
|
||||
);
|
||||
}}
|
||||
items={propertiesCombobox}
|
||||
placeholder="Select breakdown"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Filter, MoreHorizontal, Tags, Trash } from 'lucide-react';
|
||||
|
||||
const labels = [
|
||||
'feature',
|
||||
'bug',
|
||||
'enhancement',
|
||||
'documentation',
|
||||
'design',
|
||||
'question',
|
||||
'maintenance',
|
||||
];
|
||||
|
||||
export interface ReportEventMoreProps {
|
||||
onClick: (action: 'remove') => void;
|
||||
}
|
||||
|
||||
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => onClick('remove')}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
218
apps/dashboard/src/components/report/sidebar/ReportEvents.tsx
Normal file
218
apps/dashboard/src/components/report/sidebar/ReportEvents.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||
import { useEventNames } from '@/hooks/useEventNames';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
|
||||
|
||||
import type { IChartEvent } from '@mixan/validation';
|
||||
|
||||
import {
|
||||
addEvent,
|
||||
changeEvent,
|
||||
changePrevious,
|
||||
removeEvent,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { FiltersCombobox } from './filters/FiltersCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportEvents() {
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames(projectId);
|
||||
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(removeEvent(event));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Events</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event) => {
|
||||
return (
|
||||
<div key={event.name} className="rounded-lg border bg-slate-50">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<ColorSquare>{event.id}</ColorSquare>
|
||||
<Combobox
|
||||
icon={GanttChartIcon}
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={event.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
})
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
<Input
|
||||
placeholder={
|
||||
event.name ? `${event.name} (${event.id})` : 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
<div className="flex gap-2 p-2 pt-0 text-sm">
|
||||
<Dropdown
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
segment,
|
||||
})
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'event',
|
||||
label: 'All events',
|
||||
},
|
||||
{
|
||||
value: 'user',
|
||||
label: 'Unique users',
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Unique sessions',
|
||||
},
|
||||
{
|
||||
value: 'user_average',
|
||||
label: 'Average event per user',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
{
|
||||
value: 'property_sum',
|
||||
label: 'Sum of property',
|
||||
},
|
||||
{
|
||||
value: 'property_average',
|
||||
label: 'Average of property',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs bg-white">
|
||||
{event.segment === 'user' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users
|
||||
</>
|
||||
) : event.segment === 'session' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique sessions
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average event per user
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : event.segment === 'property_sum' ? (
|
||||
<>
|
||||
<Users size={12} /> Sum of property
|
||||
</>
|
||||
) : event.segment === 'property_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average of property
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GanttChart size={12} /> All events
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Dropdown>
|
||||
{/* */}
|
||||
<FiltersCombobox event={event} />
|
||||
|
||||
{(event.segment === 'property_average' ||
|
||||
event.segment === 'property_sum') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FiltersList event={event} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Combobox
|
||||
icon={GanttChartIcon}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
})
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer select-none text-sm font-medium mt-4"
|
||||
htmlFor="previous"
|
||||
>
|
||||
<Checkbox
|
||||
id="previous"
|
||||
checked={previous}
|
||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||
/>
|
||||
Show previous / Compare
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { changeFormula } from '../reportSlice';
|
||||
|
||||
export function ReportForumula() {
|
||||
const forumula = useSelector((state) => state.report.formula);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Forumula</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
placeholder="eg: A/B"
|
||||
value={forumula}
|
||||
onChange={(event) => {
|
||||
dispatch(changeFormula(event.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SheetClose, SheetFooter } from '@/components/ui/sheet';
|
||||
import { useSelector } from '@/redux';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportForumula } from './ReportForumula';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showForumula = chartType !== 'funnel';
|
||||
const showBreakdown = chartType !== 'funnel';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportEvents />
|
||||
{showForumula && <ReportForumula />}
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button className="w-full">Done</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
|
||||
import { operators } from '@mixan/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventFilterOperator,
|
||||
IChartEventFilterValue,
|
||||
} from '@mixan/validation';
|
||||
import { mapKeys } from '@mixan/validation';
|
||||
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FilterProps {
|
||||
event: IChartEvent;
|
||||
filter: IChartEvent['filters'][number];
|
||||
}
|
||||
|
||||
export function FilterItem({ filter, event }: FilterProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const getLabel = useMappings();
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chart.values.useQuery({
|
||||
event: event.name,
|
||||
property: filter.name,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const valuesCombobox =
|
||||
potentialValues.data?.values?.map((item) => ({
|
||||
value: item,
|
||||
label: getLabel(item),
|
||||
})) ?? [];
|
||||
|
||||
const removeFilter = () => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.filter((item) => item.id !== filter.id),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const changeFilterValue = (
|
||||
value: IChartEventFilterValue | IChartEventFilterValue[]
|
||||
) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === filter.id) {
|
||||
return {
|
||||
...item,
|
||||
value: Array.isArray(value) ? value : [value],
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const changeFilterOperator = (operator: IChartEventFilterOperator) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === filter.id) {
|
||||
return {
|
||||
...item,
|
||||
operator,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filter.name}
|
||||
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-slate-200 first:border-t"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<ColorSquare className="bg-emerald-500">
|
||||
<SlidersHorizontal size={10} />
|
||||
</ColorSquare>
|
||||
<div className="flex flex-1 text-sm">
|
||||
<RenderDots truncate>{filter.name}</RenderDots>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={removeFilter}>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Dropdown
|
||||
onChange={changeFilterOperator}
|
||||
items={mapKeys(operators).map((key) => ({
|
||||
value: key,
|
||||
label: operators[key],
|
||||
}))}
|
||||
label="Operator"
|
||||
>
|
||||
<Button variant={'ghost'} className="whitespace-nowrap">
|
||||
{operators[filter.operator]}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<ComboboxAdvanced
|
||||
items={valuesCombobox}
|
||||
value={filter.value}
|
||||
className="flex-1"
|
||||
onChange={(setFn) => {
|
||||
changeFilterValue(
|
||||
typeof setFn === 'function' ? setFn(filter.value) : setFn
|
||||
);
|
||||
}}
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartEvent } from '@mixan/validation';
|
||||
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FiltersComboboxProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
}
|
||||
);
|
||||
|
||||
const properties = (query.data ?? []).map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
searchable
|
||||
placeholder="Select a filter"
|
||||
value=""
|
||||
items={properties}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: (event.filters.length + 1).toString(),
|
||||
name: value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs bg-white">
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { IChartEvent } from '@mixan/validation';
|
||||
|
||||
import { FilterItem } from './FilterItem';
|
||||
|
||||
interface ReportEventFiltersProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
export function FiltersList({ event }: ReportEventFiltersProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col divide-y bg-slate-50">
|
||||
{event.filters.map((filter) => {
|
||||
return <FilterItem key={filter.name} filter={filter} event={event} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user