add grouping and totals

This commit is contained in:
2024-08-29 02:32:30 +02:00
parent 796d866a33
commit bfb0949c25
13 changed files with 636 additions and 25 deletions

View File

@@ -0,0 +1,148 @@
import React from 'react'
import { Color, colorPalette, getColorClassNames, tremorTwMerge } from '~/lib/tremor'
import { CustomTooltipProps } from '@tremor/react'
import { GroupByPeriod } from '~/services/vacancies/vacancies.types'
import { endOfMonth, endOfWeek, endOfYear, isSameDay, setDefaultOptions } from 'date-fns'
setDefaultOptions({ weekStartsOn: 1 })
export const ChartTooltipFrame = ({ children }: { children: React.ReactNode }) => (
<div
className={tremorTwMerge(
// common
'rounded-tremor-default text-tremor-default border',
// light
'bg-tremor-background shadow-tremor-dropdown border-tremor-border',
// dark
'dark:bg-dark-tremor-background dark:shadow-dark-tremor-dropdown dark:border-dark-tremor-border'
)}
>
{children}
</div>
)
export interface ChartTooltipRowProps {
value: string
name: string
color: Color | string
}
export const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => (
<div className="flex items-center justify-between space-x-8">
<div className="flex items-center space-x-2">
<span
className={tremorTwMerge(
// common
'shrink-0 rounded-tremor-full border-2 h-3 w-3',
// light
'border-tremor-background shadow-tremor-card',
// dark
'dark:border-dark-tremor-background dark:shadow-dark-tremor-card',
getColorClassNames(color, colorPalette.background).bgColor
)}
/>
<p
className={tremorTwMerge(
// commmon
'text-right whitespace-nowrap',
// light
'text-tremor-content',
// dark
'dark:text-dark-tremor-content'
)}
>
{name}
</p>
</div>
<p
className={tremorTwMerge(
// common
'font-medium tabular-nums text-right whitespace-nowrap',
// light
'text-tremor-content-emphasis',
// dark
'dark:text-dark-tremor-content-emphasis'
)}
>
{value}
</p>
</div>
)
const ChartTooltip = ({
active,
payload,
label,
groupBy,
}: CustomTooltipProps & {
rawDate: string
groupBy: GroupByPeriod
}) => {
if (active && payload) {
const rawDate = payload[0]?.payload?.rawDate
console.log(payload, rawDate)
const filteredPayload = payload.filter((item: any) => item.type !== 'none')
return (
<ChartTooltipFrame>
<div
className={tremorTwMerge(
// light
'border-tremor-border border-b px-4 py-2',
// dark
'dark:border-dark-tremor-border'
)}
>
<p
className={tremorTwMerge(
// common
'font-medium',
// light
'text-tremor-content-emphasis',
// dark
'dark:text-dark-tremor-content-emphasis'
)}
>
{formatDate(rawDate ?? '', groupBy)}
</p>
</div>
<div className={tremorTwMerge('px-4 py-2 space-y-1')}>
{filteredPayload.map(({ value, name, color }, idx: number) => (
<ChartTooltipRow
key={`id-${idx}`}
value={value?.toString() ?? ''}
name={name?.toString() ?? ''}
color={color ?? ''}
/>
))}
</div>
</ChartTooltipFrame>
)
}
return null
}
export { ChartTooltip }
function formatDate(date: string | number, groupBy: GroupByPeriod): string {
let endDate: Date
const startDate = new Date(date)
switch (groupBy) {
case 'day':
endDate = new Date(startDate)
break
case 'week':
endDate = endOfWeek(startDate)
break
case 'month':
endDate = endOfMonth(startDate)
break
case 'year':
endDate = new Date(endOfYear(startDate))
break
default:
endDate = new Date(startDate)
}
return isSameDay(startDate, endDate)
? startDate.toLocaleDateString('ru')
: `${startDate.toLocaleDateString('ru')} - ${endDate.toLocaleDateString('ru')}`
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useMemo } from 'react'
import { KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react'
import { GroupByPeriod, KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem, Switch } from '@tremor/react'
import { useSearchParams } from '@remix-run/react'
import { capitalize } from 'remeda'
import { ChartTooltip } from '~/components/ui/chart-tooltip'
type Props = {
data?: VacancyData
@@ -32,6 +34,11 @@ export function VacanciesChart({ data, keywords }: Props) {
return searchParams.get('preset') ?? 'None'
}, [searchParams])
const showTotal = useMemo(() => searchParams.get('showTotal') === 'true', [searchParams])
const groupBy: GroupByPeriod = useMemo(() => {
return (searchParams.get('groupBy') ?? 'day') as GroupByPeriod
}, [searchParams])
const setPreset = useCallback(
(value: string | null) => {
if (!value || value === 'None') {
@@ -44,10 +51,27 @@ export function VacanciesChart({ data, keywords }: Props) {
},
[searchParams, setSearchParams]
)
const setShowTotal = useCallback(
(value: boolean) => {
if (value) {
searchParams.set('showTotal', 'true')
} else {
searchParams.delete('showTotal')
}
setSearchParams(searchParams)
},
[searchParams, setSearchParams]
)
const categoriesForChart = useMemo(() => {
return data?.categories.filter(category => selectedCategories.includes(category)) ?? []
}, [selectedCategories, data?.categories])
const filtered = [
...(data?.categories.filter(category => selectedCategories.includes(category)) ?? []),
]
if (showTotal) {
filtered.unshift('total')
}
return filtered
}, [selectedCategories, data?.categories, showTotal])
const setSelectedCategories = useCallback(
(value: string[]) => {
@@ -62,17 +86,42 @@ export function VacanciesChart({ data, keywords }: Props) {
[searchParams, setSearchParams]
)
const setGroupBy = useCallback(
(value: string) => {
if (!value || value === 'day') {
searchParams.delete('groupBy')
} else {
searchParams.set('groupBy', value)
}
setSearchParams(searchParams)
},
[searchParams, setSearchParams]
)
const filteredData = useMemo(
() =>
data?.data?.filter(row => {
for (const category of selectedCategories) {
const value = row[category]
if (typeof value === 'number' && value > 0) {
return true
data?.data
?.filter(row => {
for (const category of selectedCategories) {
const value = row[category]
if (typeof value === 'number' && value > 0) {
return true
}
}
}
}) ?? [],
[data?.data, selectedCategories]
if (showTotal) {
const value = row['total']
if (typeof value === 'number' && value > 0) {
return true
}
}
})
?.map(({ date, ...rest }) => ({
...rest,
date: new Date(date).toLocaleDateString('ru'),
rawDate: date,
})) ?? [],
[data?.data, selectedCategories, showTotal, groupBy]
)
const handlePresetChange = useCallback(
@@ -103,6 +152,16 @@ export function VacanciesChart({ data, keywords }: Props) {
))
}, [presetsForSelect])
const groupBySelectItems = useMemo(
() =>
Object.entries(GroupByPeriod).map(([label, value]) => (
<SelectItem key={'groupBy-select-item-' + label} value={value}>
{capitalize(value)}
</SelectItem>
)),
[]
)
return (
<>
<div className={'flex gap-4'}>
@@ -121,6 +180,7 @@ export function VacanciesChart({ data, keywords }: Props) {
{multiSelectItems}
</MultiSelect>
</div>
<div className="max-w-xl">
<label
htmlFor="presets"
@@ -137,9 +197,31 @@ export function VacanciesChart({ data, keywords }: Props) {
{presetSelectItems}
</Select>
</div>
<div className="max-w-xl">
<label
htmlFor="groupBy"
className="text-tremor-default text-tremor-content dark:text-dark-tremor-content"
>
Group by
</label>
<Select value={groupBy} id={'groupBy'} defaultValue={'day'} onValueChange={setGroupBy}>
{groupBySelectItems}
</Select>
</div>
<label
htmlFor="Show total"
className="max-w-xl flex self-center mt-6 gap-2 text-tremor-default text-tremor-content dark:text-dark-tremor-content select-none"
>
Show total
<Switch checked={showTotal} onChange={setShowTotal} id={'Show total'} />
</label>
</div>
<AreaChart
connectNulls={true}
customTooltip={args => (
<ChartTooltip {...args} groupBy={groupBy} rawDate={args.payload?.payload?.rawDate} />
)}
enableLegendSlider
className="h-full"
data={filteredData}
index="date"