@tuturuuu/ui 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/biome.json +1 -1
- package/package.json +53 -51
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
- package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
- package/src/components/ui/chat/ai-message-parts.tsx +23 -16
- package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
- package/src/components/ui/commercial-comparison-style.ts +104 -0
- package/src/components/ui/commercial-comparison-table.tsx +159 -0
- package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
- package/src/components/ui/commercial-comparison.test.tsx +86 -0
- package/src/components/ui/commercial-comparison.tsx +224 -0
- package/src/components/ui/comparison-value.test.tsx +32 -0
- package/src/components/ui/comparison-value.tsx +72 -0
- package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
- package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
- package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
- package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
- package/src/components/ui/custom/report-preview-pagination.ts +5 -3
- package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
- package/src/components/ui/custom/report-preview.tsx +2 -3
- package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
- package/src/components/ui/finance/invoices/columns.tsx +2 -0
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
- package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
- package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- package/src/components/ui/finance/invoices/hooks.ts +5 -110
- package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
- package/src/components/ui/finance/invoices/internal-api.ts +19 -1
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
- package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
- package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
- package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
- package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
- package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
- package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
- package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
- package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
- package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
- package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
- package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
- package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
- package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- package/src/components/ui/legacy/calendar/working-location.ts +35 -0
- package/src/components/ui/public-workspace-prices.tsx +13 -0
- package/src/components/ui/pwa-status.test.tsx +13 -2
- package/src/components/ui/pwa-status.tsx +15 -3
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
- package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
- package/src/hooks/calendar-provider-sync.ts +33 -0
- package/src/hooks/notification-subscription.ts +74 -0
- package/src/hooks/use-calendar-sync.tsx +11 -14
- package/src/hooks/use-calendar.tsx +2 -2
- package/src/hooks/use-notifications.ts +5 -141
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
Bar,
|
|
4
|
-
BarChart,
|
|
5
|
-
CartesianGrid,
|
|
6
|
-
Legend,
|
|
7
|
-
Tooltip,
|
|
8
|
-
XAxis,
|
|
9
|
-
YAxis,
|
|
10
|
-
} from 'recharts';
|
|
1
|
+
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
|
|
11
2
|
import type { ChartConfig } from '../../../chart';
|
|
12
3
|
import { ChartContainer } from '../../../chart';
|
|
13
4
|
import { CategoryBreakdownLegend } from './category-breakdown-chart-legend';
|
|
@@ -28,7 +19,7 @@ interface CategoryBreakdownChartBodyProps {
|
|
|
28
19
|
hiddenCategories: Set<string>;
|
|
29
20
|
interval: ChartInterval;
|
|
30
21
|
locale: string;
|
|
31
|
-
onToggleCategory: (
|
|
22
|
+
onToggleCategory: (key: string) => void;
|
|
32
23
|
}
|
|
33
24
|
|
|
34
25
|
export function CategoryBreakdownChartBody({
|
|
@@ -43,76 +34,78 @@ export function CategoryBreakdownChartBody({
|
|
|
43
34
|
onToggleCategory,
|
|
44
35
|
}: CategoryBreakdownChartBodyProps) {
|
|
45
36
|
return (
|
|
46
|
-
<
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<Legend
|
|
68
|
-
content={(props) => (
|
|
69
|
-
<CategoryBreakdownLegend
|
|
70
|
-
hiddenCategories={hiddenCategories}
|
|
71
|
-
onToggleCategory={onToggleCategory}
|
|
72
|
-
payload={props.payload}
|
|
73
|
-
/>
|
|
74
|
-
)}
|
|
75
|
-
iconType="rect"
|
|
76
|
-
iconSize={12}
|
|
77
|
-
/>
|
|
78
|
-
<Tooltip
|
|
79
|
-
cursor={{ fill: 'hsl(var(--foreground))', opacity: 0.05 }}
|
|
80
|
-
wrapperStyle={{
|
|
81
|
-
outline: 'none',
|
|
82
|
-
zIndex: 100,
|
|
83
|
-
}}
|
|
84
|
-
contentStyle={{
|
|
85
|
-
backgroundColor: 'transparent',
|
|
86
|
-
border: 'none',
|
|
87
|
-
padding: 0,
|
|
88
|
-
boxShadow: 'none',
|
|
89
|
-
}}
|
|
90
|
-
content={
|
|
91
|
-
<CategoryBreakdownTooltipContent
|
|
92
|
-
locale={locale}
|
|
93
|
-
interval={interval}
|
|
94
|
-
formatValue={formatValue}
|
|
95
|
-
categories={categories}
|
|
96
|
-
hiddenCategories={hiddenCategories}
|
|
97
|
-
/>
|
|
98
|
-
}
|
|
99
|
-
/>
|
|
100
|
-
{categories.map((category) => (
|
|
101
|
-
<Bar
|
|
102
|
-
key={category.id || 'uncategorized'}
|
|
103
|
-
dataKey={category.name}
|
|
104
|
-
stackId="categories"
|
|
105
|
-
fill={
|
|
106
|
-
hiddenCategories.has(category.name)
|
|
107
|
-
? 'transparent'
|
|
108
|
-
: category.color
|
|
37
|
+
<div className="grid min-w-0 gap-5 xl:grid-cols-[minmax(0,1fr)_18rem]">
|
|
38
|
+
<ChartContainer
|
|
39
|
+
config={chartConfig}
|
|
40
|
+
className="aspect-auto h-72 w-full min-w-0 sm:h-80"
|
|
41
|
+
>
|
|
42
|
+
<BarChart data={chartData}>
|
|
43
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.3} />
|
|
44
|
+
<XAxis
|
|
45
|
+
dataKey="period"
|
|
46
|
+
tickLine={false}
|
|
47
|
+
axisLine={false}
|
|
48
|
+
tickFormatter={(value) =>
|
|
49
|
+
formatCategoryBreakdownXAxisTick(String(value), interval, locale)
|
|
50
|
+
}
|
|
51
|
+
tick={{ fill: 'var(--foreground)', opacity: 0.7 }}
|
|
52
|
+
/>
|
|
53
|
+
<YAxis
|
|
54
|
+
tickLine={false}
|
|
55
|
+
axisLine={false}
|
|
56
|
+
tickFormatter={(value) =>
|
|
57
|
+
typeof value === 'number' ? formatCompactValue(value) : value
|
|
109
58
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
hide={hiddenCategories.has(category.name)}
|
|
59
|
+
tick={{ fill: 'var(--foreground)', opacity: 0.7 }}
|
|
60
|
+
width={60}
|
|
113
61
|
/>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
62
|
+
<Tooltip
|
|
63
|
+
cursor={{ fill: 'var(--foreground)', opacity: 0.05 }}
|
|
64
|
+
wrapperStyle={{
|
|
65
|
+
outline: 'none',
|
|
66
|
+
zIndex: 100,
|
|
67
|
+
}}
|
|
68
|
+
contentStyle={{
|
|
69
|
+
backgroundColor: 'transparent',
|
|
70
|
+
border: 'none',
|
|
71
|
+
padding: 0,
|
|
72
|
+
boxShadow: 'none',
|
|
73
|
+
}}
|
|
74
|
+
content={
|
|
75
|
+
<CategoryBreakdownTooltipContent
|
|
76
|
+
locale={locale}
|
|
77
|
+
interval={interval}
|
|
78
|
+
formatValue={formatValue}
|
|
79
|
+
categories={categories}
|
|
80
|
+
hiddenCategories={hiddenCategories}
|
|
81
|
+
/>
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
{categories.map((category) => (
|
|
85
|
+
<Bar
|
|
86
|
+
key={category.key}
|
|
87
|
+
dataKey={category.key}
|
|
88
|
+
name={category.name}
|
|
89
|
+
stackId="categories"
|
|
90
|
+
fill={
|
|
91
|
+
hiddenCategories.has(category.key)
|
|
92
|
+
? 'transparent'
|
|
93
|
+
: category.color
|
|
94
|
+
}
|
|
95
|
+
radius={[0, 0, 0, 0]}
|
|
96
|
+
maxBarSize={50}
|
|
97
|
+
isAnimationActive={false}
|
|
98
|
+
hide={hiddenCategories.has(category.key)}
|
|
99
|
+
/>
|
|
100
|
+
))}
|
|
101
|
+
</BarChart>
|
|
102
|
+
</ChartContainer>
|
|
103
|
+
<CategoryBreakdownLegend
|
|
104
|
+
categories={categories}
|
|
105
|
+
hiddenCategories={hiddenCategories}
|
|
106
|
+
onToggleCategory={onToggleCategory}
|
|
107
|
+
formatValue={formatValue}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
117
110
|
);
|
|
118
111
|
}
|
|
@@ -50,7 +50,7 @@ export function CategoryBreakdownChartControls({
|
|
|
50
50
|
const t = useTranslations();
|
|
51
51
|
|
|
52
52
|
return (
|
|
53
|
-
<div className="flex flex-col gap-3
|
|
53
|
+
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
54
54
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
55
55
|
<CardTitle className="text-base sm:text-lg">{chartTitle}</CardTitle>
|
|
56
56
|
<ToggleGroup
|
|
@@ -103,6 +103,7 @@ export function CategoryBreakdownChartControls({
|
|
|
103
103
|
onClick={onPreviousPeriod}
|
|
104
104
|
className="h-7 w-7"
|
|
105
105
|
title={t('finance-analytics.previous-period')}
|
|
106
|
+
aria-label={t('finance-analytics.previous-period')}
|
|
106
107
|
>
|
|
107
108
|
<ChevronLeft className="h-4 w-4" />
|
|
108
109
|
</Button>
|
|
@@ -116,6 +117,7 @@ export function CategoryBreakdownChartControls({
|
|
|
116
117
|
disabled={dateOffset === 0}
|
|
117
118
|
className="h-7 w-7"
|
|
118
119
|
title={t('finance-analytics.next-period')}
|
|
120
|
+
aria-label={t('finance-analytics.next-period')}
|
|
119
121
|
>
|
|
120
122
|
<ChevronRight className="h-4 w-4" />
|
|
121
123
|
</Button>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { CategoryBreakdownLegend } from './category-breakdown-chart-legend';
|
|
4
|
+
|
|
5
|
+
describe('category legend', () => {
|
|
6
|
+
it('toggles a category by stable id and exposes visibility to keyboard users', () => {
|
|
7
|
+
const toggle = vi.fn();
|
|
8
|
+
render(
|
|
9
|
+
<CategoryBreakdownLegend
|
|
10
|
+
categories={[
|
|
11
|
+
{
|
|
12
|
+
key: 'a',
|
|
13
|
+
id: 'a',
|
|
14
|
+
name: 'Office',
|
|
15
|
+
total: 50,
|
|
16
|
+
color: 'var(--chart-1)',
|
|
17
|
+
},
|
|
18
|
+
]}
|
|
19
|
+
hiddenCategories={new Set(['a'])}
|
|
20
|
+
onToggleCategory={toggle}
|
|
21
|
+
formatValue={() => '•••'}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
const button = screen.getByRole('button', { name: 'Office •••' });
|
|
25
|
+
expect(button.getAttribute('aria-pressed')).toBe('false');
|
|
26
|
+
fireEvent.click(button);
|
|
27
|
+
expect(toggle).toHaveBeenCalledWith('a');
|
|
28
|
+
expect(screen.queryByText('50')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -1,45 +1,52 @@
|
|
|
1
1
|
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
-
import type {
|
|
2
|
+
import type { CategoryBreakdownCategory } from './category-breakdown-chart-types';
|
|
3
3
|
|
|
4
4
|
interface CategoryBreakdownLegendProps {
|
|
5
|
+
categories: CategoryBreakdownCategory[];
|
|
5
6
|
hiddenCategories: Set<string>;
|
|
6
|
-
onToggleCategory: (
|
|
7
|
-
|
|
7
|
+
onToggleCategory: (key: string) => void;
|
|
8
|
+
formatValue: (value: number) => string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function CategoryBreakdownLegend({
|
|
12
|
+
categories,
|
|
11
13
|
hiddenCategories,
|
|
12
14
|
onToggleCategory,
|
|
13
|
-
|
|
15
|
+
formatValue,
|
|
14
16
|
}: CategoryBreakdownLegendProps) {
|
|
15
|
-
if (!payload) return null;
|
|
16
|
-
|
|
17
17
|
return (
|
|
18
|
-
<div className="
|
|
19
|
-
{
|
|
20
|
-
const
|
|
18
|
+
<div className="grid max-h-64 content-start gap-1 overflow-y-auto rounded-lg border bg-muted/20 p-2 sm:grid-cols-2 xl:max-h-80 xl:grid-cols-1">
|
|
19
|
+
{categories.map((category) => {
|
|
20
|
+
const hidden = hiddenCategories.has(category.key);
|
|
21
21
|
return (
|
|
22
22
|
<button
|
|
23
|
-
key={
|
|
23
|
+
key={category.key}
|
|
24
24
|
type="button"
|
|
25
|
-
|
|
25
|
+
aria-pressed={!hidden}
|
|
26
|
+
aria-label={`${category.name} ${formatValue(category.total)}`}
|
|
27
|
+
onClick={() => onToggleCategory(category.key)}
|
|
26
28
|
className={cn(
|
|
27
|
-
'flex
|
|
28
|
-
|
|
29
|
+
'flex min-w-0 items-start gap-2 rounded-md p-2 text-left text-xs transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
30
|
+
hidden && 'opacity-50'
|
|
29
31
|
)}
|
|
30
32
|
>
|
|
31
|
-
<div
|
|
32
|
-
className="h-3 w-3 rounded-sm"
|
|
33
|
-
style={{
|
|
34
|
-
backgroundColor: entry.color,
|
|
35
|
-
opacity: isHidden ? 0.4 : 1,
|
|
36
|
-
}}
|
|
37
|
-
/>
|
|
38
33
|
<span
|
|
39
|
-
className=
|
|
40
|
-
style={{
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
className="mt-1 size-2.5 shrink-0 rounded-sm"
|
|
35
|
+
style={{ backgroundColor: category.color }}
|
|
36
|
+
/>
|
|
37
|
+
<span className="min-w-0 flex-1">
|
|
38
|
+
<span
|
|
39
|
+
className={cn(
|
|
40
|
+
'line-clamp-2 break-words font-medium',
|
|
41
|
+
hidden && 'line-through'
|
|
42
|
+
)}
|
|
43
|
+
title={category.name}
|
|
44
|
+
>
|
|
45
|
+
{category.name}
|
|
46
|
+
</span>
|
|
47
|
+
<span className="mt-1 block text-muted-foreground tabular-nums">
|
|
48
|
+
{formatValue(category.total)}
|
|
49
|
+
</span>
|
|
43
50
|
</span>
|
|
44
51
|
</button>
|
|
45
52
|
);
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
|
|
7
7
|
interface TooltipPayloadItem {
|
|
8
8
|
name: string;
|
|
9
|
+
dataKey?: string;
|
|
9
10
|
value: number;
|
|
10
11
|
color?: string;
|
|
11
12
|
}
|
|
@@ -36,7 +37,7 @@ export function CategoryBreakdownTooltipContent({
|
|
|
36
37
|
if (!active || !payload || payload.length === 0) return null;
|
|
37
38
|
|
|
38
39
|
const sortedPayload = [...payload]
|
|
39
|
-
.filter((item) => !hiddenCategories.has(item.name))
|
|
40
|
+
.filter((item) => !hiddenCategories.has(item.dataKey ?? item.name))
|
|
40
41
|
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
41
42
|
|
|
42
43
|
if (sortedPayload.length === 0) return null;
|
|
@@ -78,16 +79,16 @@ export function CategoryBreakdownTooltipContent({
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
return (
|
|
81
|
-
<div className="rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
|
|
82
|
+
<div className="max-w-sm rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
|
|
82
83
|
<p className="mb-2 font-semibold">{formattedLabel}</p>
|
|
83
|
-
<div className="space-y-1">
|
|
84
|
+
<div className="max-h-64 space-y-1 overflow-y-auto">
|
|
84
85
|
{sortedPayload.map((item) => {
|
|
85
|
-
const category = categories.find((c) => c.
|
|
86
|
+
const category = categories.find((c) => c.key === item.dataKey);
|
|
86
87
|
const color = category?.color || item.color;
|
|
87
88
|
|
|
88
89
|
return (
|
|
89
90
|
<div
|
|
90
|
-
key={item.name}
|
|
91
|
+
key={item.dataKey ?? item.name}
|
|
91
92
|
className="flex items-center justify-between gap-4"
|
|
92
93
|
>
|
|
93
94
|
<div className="flex items-center gap-2">
|
|
@@ -95,7 +96,9 @@ export function CategoryBreakdownTooltipContent({
|
|
|
95
96
|
className="h-3 w-3 rounded-sm"
|
|
96
97
|
style={{ backgroundColor: color }}
|
|
97
98
|
/>
|
|
98
|
-
<span className="text-sm">
|
|
99
|
+
<span className="line-clamp-2 break-words text-sm">
|
|
100
|
+
{item.name}
|
|
101
|
+
</span>
|
|
99
102
|
</div>
|
|
100
103
|
<span className="font-semibold text-sm" style={{ color }}>
|
|
101
104
|
{formatValue(item.value)}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildCategoryBreakdownChartData } from './category-breakdown-chart-utils';
|
|
3
|
+
|
|
4
|
+
const point = (
|
|
5
|
+
id: string,
|
|
6
|
+
name: string,
|
|
7
|
+
total: number,
|
|
8
|
+
period = '2026-09-01'
|
|
9
|
+
) => ({
|
|
10
|
+
category_id: id,
|
|
11
|
+
category_name: name,
|
|
12
|
+
category_color: null,
|
|
13
|
+
category_icon: null,
|
|
14
|
+
total,
|
|
15
|
+
period,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('category breakdown series', () => {
|
|
19
|
+
it('keeps categories with the same name separate and sums repeated points', () => {
|
|
20
|
+
const result = buildCategoryBreakdownChartData([
|
|
21
|
+
point('a', 'Office', 10),
|
|
22
|
+
point('b', 'Office', 20),
|
|
23
|
+
point('a', 'Office', 5),
|
|
24
|
+
]);
|
|
25
|
+
expect(result.categories.map((category) => category.total)).toEqual([
|
|
26
|
+
20, 15,
|
|
27
|
+
]);
|
|
28
|
+
expect(result.chartData[0]).toMatchObject({
|
|
29
|
+
category_a: 15,
|
|
30
|
+
category_b: 20,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
it('groups smaller categories without losing any period totals', () => {
|
|
34
|
+
const result = buildCategoryBreakdownChartData(
|
|
35
|
+
[
|
|
36
|
+
point('a', 'A', 100),
|
|
37
|
+
point('b', 'B', 20),
|
|
38
|
+
point('c', 'C', 5),
|
|
39
|
+
point('b', 'B', 3, '2026-08-01'),
|
|
40
|
+
],
|
|
41
|
+
{ limit: 1, otherLabel: 'Other' }
|
|
42
|
+
);
|
|
43
|
+
expect(
|
|
44
|
+
result.categories.map((category) => [category.name, category.total])
|
|
45
|
+
).toEqual([
|
|
46
|
+
['A', 100],
|
|
47
|
+
['Other', 28],
|
|
48
|
+
]);
|
|
49
|
+
expect(result.chartData).toEqual([
|
|
50
|
+
{ period: '2026-08-01', other_categories: 3 },
|
|
51
|
+
{ period: '2026-09-01', category_a: 100, other_categories: 25 },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
it('keeps arbitrary names out of chart data keys', () => {
|
|
55
|
+
expect(
|
|
56
|
+
buildCategoryBreakdownChartData([point('a', 'period', 10)]).chartData[0]
|
|
57
|
+
).toEqual({ period: '2026-09-01', category_a: 10 });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -131,50 +131,64 @@ export function getActualCategoryBreakdownDisplayRange(
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
export function buildCategoryBreakdownChartData(
|
|
134
|
-
rawData: FinanceCategoryBreakdownPoint[]
|
|
134
|
+
rawData: FinanceCategoryBreakdownPoint[],
|
|
135
|
+
options?: { limit: number; otherLabel: string }
|
|
135
136
|
): {
|
|
136
137
|
categories: CategoryBreakdownCategory[];
|
|
137
138
|
chartData: CategoryBreakdownChartDatum[];
|
|
138
139
|
} {
|
|
139
|
-
if (rawData.length === 0) {
|
|
140
|
-
return { chartData: [], categories: [] };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
140
|
const categoryMap = new Map<string, CategoryBreakdownCategory>();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
141
|
+
for (const item of rawData) {
|
|
142
|
+
const key = `category_${item.category_id ?? 'uncategorized'}`;
|
|
143
|
+
const existing = categoryMap.get(key);
|
|
144
|
+
if (existing) {
|
|
145
|
+
existing.total += Number(item.total) || 0;
|
|
146
|
+
} else {
|
|
147
|
+
categoryMap.set(key, {
|
|
148
|
+
key,
|
|
149
|
+
id: item.category_id,
|
|
150
|
+
name: item.category_name,
|
|
151
|
+
total: Number(item.total) || 0,
|
|
152
|
+
color:
|
|
153
|
+
item.category_color ||
|
|
154
|
+
DEFAULT_CATEGORY_COLORS[
|
|
155
|
+
categoryMap.size % DEFAULT_CATEGORY_COLORS.length
|
|
156
|
+
]!,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const ranked = [...categoryMap.values()].sort(
|
|
161
|
+
(a, b) => b.total - a.total || a.key.localeCompare(b.key)
|
|
162
|
+
);
|
|
163
|
+
const visible = options ? ranked.slice(0, options.limit) : ranked;
|
|
164
|
+
const visibleKeys = new Set(visible.map((category) => category.key));
|
|
165
|
+
const remainder = ranked.slice(visible.length);
|
|
166
|
+
const categories =
|
|
167
|
+
remainder.length > 0
|
|
168
|
+
? [
|
|
169
|
+
...visible,
|
|
170
|
+
{
|
|
171
|
+
key: 'other_categories',
|
|
172
|
+
id: null,
|
|
173
|
+
name: options!.otherLabel,
|
|
174
|
+
total: remainder.reduce((sum, category) => sum + category.total, 0),
|
|
175
|
+
color: 'var(--muted-foreground)',
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
: visible;
|
|
163
179
|
const periodMap = new Map<string, CategoryBreakdownChartDatum>();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!periodMap.has(item.period)) {
|
|
180
|
+
for (const item of rawData) {
|
|
181
|
+
if (!periodMap.has(item.period))
|
|
167
182
|
periodMap.set(item.period, { period: item.period });
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const chartData =
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return { chartData, categories: Array.from(categoryMap.values()) };
|
|
183
|
+
const period = periodMap.get(item.period)!;
|
|
184
|
+
const categoryKey = `category_${item.category_id ?? 'uncategorized'}`;
|
|
185
|
+
const key = visibleKeys.has(categoryKey) ? categoryKey : 'other_categories';
|
|
186
|
+
period[key] = Number(period[key] || 0) + (Number(item.total) || 0);
|
|
187
|
+
}
|
|
188
|
+
const chartData = [...periodMap.values()].sort(
|
|
189
|
+
(a, b) => new Date(a.period).getTime() - new Date(b.period).getTime()
|
|
190
|
+
);
|
|
191
|
+
return { categories, chartData };
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
export function formatCategoryBreakdownXAxisTick(
|
|
@@ -5,7 +5,7 @@ import { listFinanceCategoryBreakdown } from '@tuturuuu/internal-api/finance';
|
|
|
5
5
|
import { cn, getCurrencyLocale } from '@tuturuuu/utils/format';
|
|
6
6
|
import { useLocale, useTranslations } from 'next-intl';
|
|
7
7
|
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
|
8
|
-
import
|
|
8
|
+
import { Button } from '../../../button';
|
|
9
9
|
import { Card, CardContent, CardHeader } from '../../../card';
|
|
10
10
|
import type { ChartConfig } from '../../../chart';
|
|
11
11
|
import { Skeleton } from '../../../skeleton';
|
|
@@ -47,6 +47,7 @@ export function CategoryBreakdownChart({
|
|
|
47
47
|
const { isConfidential, toggleConfidential } =
|
|
48
48
|
useFinanceConfidentialVisibility();
|
|
49
49
|
const shouldHideAmounts = isConfidential || !includeConfidential;
|
|
50
|
+
const [showAll, setShowAll] = useState(false);
|
|
50
51
|
const [dateOffset, setDateOffset] = useState(0);
|
|
51
52
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(
|
|
52
53
|
new Set()
|
|
@@ -110,8 +111,14 @@ export function CategoryBreakdownChart({
|
|
|
110
111
|
);
|
|
111
112
|
|
|
112
113
|
const { chartData, categories } = useMemo(
|
|
113
|
-
() =>
|
|
114
|
-
|
|
114
|
+
() =>
|
|
115
|
+
buildCategoryBreakdownChartData(
|
|
116
|
+
rawData,
|
|
117
|
+
showAll
|
|
118
|
+
? undefined
|
|
119
|
+
: { limit: 7, otherLabel: t('finance-analytics.other-categories') }
|
|
120
|
+
),
|
|
121
|
+
[rawData, showAll, t]
|
|
115
122
|
);
|
|
116
123
|
|
|
117
124
|
const intervalLabels: Record<ChartInterval, string> = useMemo(
|
|
@@ -133,7 +140,7 @@ export function CategoryBreakdownChart({
|
|
|
133
140
|
|
|
134
141
|
const chartConfig = useMemo<ChartConfig>(() => {
|
|
135
142
|
return categories.reduce((config, category) => {
|
|
136
|
-
config[category.
|
|
143
|
+
config[category.key] = {
|
|
137
144
|
label: category.name,
|
|
138
145
|
color: category.color,
|
|
139
146
|
};
|
|
@@ -166,8 +173,7 @@ export function CategoryBreakdownChart({
|
|
|
166
173
|
[shouldHideAmounts, locale]
|
|
167
174
|
);
|
|
168
175
|
|
|
169
|
-
const handleLegendClick = useCallback((
|
|
170
|
-
const categoryName = entry.value as string;
|
|
176
|
+
const handleLegendClick = useCallback((categoryName: string) => {
|
|
171
177
|
setHiddenCategories((prev) => {
|
|
172
178
|
const next = new Set(prev);
|
|
173
179
|
if (next.has(categoryName)) {
|
|
@@ -246,7 +252,31 @@ export function CategoryBreakdownChart({
|
|
|
246
252
|
}
|
|
247
253
|
|
|
248
254
|
return renderCard(
|
|
249
|
-
<CardContent className="px-
|
|
255
|
+
<CardContent className="min-w-0 space-y-3 px-4 pb-4 sm:px-6">
|
|
256
|
+
<div className="flex flex-wrap items-center justify-between gap-2 text-muted-foreground text-xs">
|
|
257
|
+
<p>
|
|
258
|
+
{t(
|
|
259
|
+
showAll
|
|
260
|
+
? 'finance-analytics.all-categories-description'
|
|
261
|
+
: 'finance-analytics.top-categories-description'
|
|
262
|
+
)}
|
|
263
|
+
</p>
|
|
264
|
+
<Button
|
|
265
|
+
variant="ghost"
|
|
266
|
+
size="sm"
|
|
267
|
+
onClick={() => {
|
|
268
|
+
setShowAll(!showAll);
|
|
269
|
+
setHiddenCategories(new Set());
|
|
270
|
+
}}
|
|
271
|
+
aria-expanded={showAll}
|
|
272
|
+
>
|
|
273
|
+
{t(
|
|
274
|
+
showAll
|
|
275
|
+
? 'finance-analytics.show-top-categories'
|
|
276
|
+
: 'finance-analytics.show-all-categories'
|
|
277
|
+
)}
|
|
278
|
+
</Button>
|
|
279
|
+
</div>
|
|
250
280
|
<CategoryBreakdownChartBody
|
|
251
281
|
categories={categories}
|
|
252
282
|
chartConfig={chartConfig}
|
|
@@ -12,8 +12,9 @@ import timezone from 'dayjs/plugin/timezone';
|
|
|
12
12
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { MIN_COLUMN_WIDTH } from './config';
|
|
14
14
|
import { CalendarEventProviderIcon } from './event-provider-display';
|
|
15
|
-
import {
|
|
15
|
+
import { LocationTimeline } from './location-timeline';
|
|
16
16
|
import { useCalendarSettings } from './settings/settings-context';
|
|
17
|
+
import { getEventLocationType } from './working-location';
|
|
17
18
|
|
|
18
19
|
dayjs.extend(isBetween);
|
|
19
20
|
dayjs.extend(isSameOrAfter);
|
|
@@ -22,7 +23,6 @@ dayjs.extend(timezone);
|
|
|
22
23
|
|
|
23
24
|
const MAX_EVENTS_DISPLAY = 2;
|
|
24
25
|
|
|
25
|
-
// Define types for better type safety
|
|
26
26
|
interface EventSpan {
|
|
27
27
|
event: CalendarEvent;
|
|
28
28
|
startIndex: number;
|
|
@@ -490,11 +490,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
490
490
|
// Location events will be rendered separately as a compact timeline strip
|
|
491
491
|
// Extract location spans before filtering them out for row assignment
|
|
492
492
|
const locationSpansFromMerge = mergedTempSpans
|
|
493
|
-
.filter((span) =>
|
|
493
|
+
.filter((span) => getEventLocationType(span.event) !== null)
|
|
494
494
|
.map((span) => ({ ...span, row: 0 })); // Give them row 0 as placeholder
|
|
495
495
|
|
|
496
496
|
const nonLocationSpans = mergedTempSpans.filter(
|
|
497
|
-
(span) =>
|
|
497
|
+
(span) => getEventLocationType(span.event) === null
|
|
498
498
|
);
|
|
499
499
|
|
|
500
500
|
// Sort: 1) by span length (longer events first for better packing), 2) by start date
|
|
@@ -628,7 +628,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
628
628
|
// Get non-location spans for regular event display
|
|
629
629
|
const regularSpans = useMemo(() => {
|
|
630
630
|
return eventLayout.spans.filter(
|
|
631
|
-
(span) =>
|
|
631
|
+
(span) => getEventLocationType(span.event) === null
|
|
632
632
|
);
|
|
633
633
|
}, [eventLayout.spans]);
|
|
634
634
|
|