@tuturuuu/ui 0.31.0 → 0.33.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
  11. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  12. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  13. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  14. package/src/components/ui/commercial-comparison-style.ts +104 -0
  15. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  16. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  17. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  18. package/src/components/ui/commercial-comparison.tsx +224 -0
  19. package/src/components/ui/comparison-value.test.tsx +32 -0
  20. package/src/components/ui/comparison-value.tsx +72 -0
  21. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  22. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  23. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  24. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  25. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  26. package/src/components/ui/custom/report-preview.tsx +2 -3
  27. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  28. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  29. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  31. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  33. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  34. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  35. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  36. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  37. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  38. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  39. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  40. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  41. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  42. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  43. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  44. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  45. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  47. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  48. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  49. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  50. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  51. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  52. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  53. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  54. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  55. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  64. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  65. package/src/components/ui/public-workspace-prices.tsx +13 -0
  66. package/src/components/ui/pwa-status.test.tsx +13 -2
  67. package/src/components/ui/pwa-status.tsx +15 -3
  68. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  69. package/src/components/ui/text-editor/editor.tsx +11 -3
  70. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  71. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  72. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  73. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  74. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  75. package/src/hooks/calendar-provider-sync.ts +33 -0
  76. package/src/hooks/notification-subscription.ts +74 -0
  77. package/src/hooks/use-calendar-sync.tsx +11 -14
  78. package/src/hooks/use-calendar.tsx +2 -2
  79. package/src/hooks/use-notifications.ts +5 -141
@@ -0,0 +1,20 @@
1
+ import type { FinancePermissionRequestUser } from '../shared/finance-permission-warning-dialog';
2
+
3
+ export interface SubscriptionInvoiceProps {
4
+ wsId: string;
5
+ prefillQuantity?: number | null;
6
+ suggestedTotal?: number | null;
7
+ createMultipleInvoices: boolean;
8
+ printAfterCreate?: boolean;
9
+ downloadImageAfterCreate?: boolean;
10
+ defaultWalletId?: string;
11
+ defaultCategoryId?: string;
12
+ defaultCurrency?: string;
13
+ workspaceTimezone?: string | null;
14
+ canChangeFinanceWallets?: boolean;
15
+ canSetFinanceWalletsOnCreate?: boolean;
16
+ canReadInvoiceProducts?: boolean;
17
+ canReadInvoiceProductStock?: boolean;
18
+ canReadGroupLinkedProducts?: boolean;
19
+ permissionRequestUser?: FinancePermissionRequestUser | null;
20
+ }
@@ -1,5 +1,5 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
- import { render, waitFor } from '@testing-library/react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import type { ComponentProps, PropsWithChildren } from 'react';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { SubscriptionInvoice } from './subscription-invoice';
@@ -37,6 +37,15 @@ const testState = vi.hoisted(() => {
37
37
  setMonth: vi.fn(),
38
38
  productSelectionInjected: false,
39
39
  products: [product],
40
+ productsError: null as Error | null,
41
+ userGroupsError: null as Error | null,
42
+ groupProductsError: null as Error | null,
43
+ contextError: null as Error | null,
44
+ refetchUserGroups: vi.fn(),
45
+ refetchGroupProducts: vi.fn(),
46
+ refetchContext: vi.fn(),
47
+ refetchProducts: vi.fn(),
48
+ refetchUsers: vi.fn(),
40
49
  selectedProducts: [{ inventory, product, quantity: 1 }],
41
50
  useCategories: vi.fn(),
42
51
  useWallets: vi.fn(),
@@ -170,6 +179,7 @@ vi.mock('./hooks', () => ({
170
179
  useInvoiceBlockedGroups: () => ({ data: [] }),
171
180
  useInvoiceCustomerSearch: () => ({
172
181
  customers: [],
182
+ refetch: testState.refetchUsers,
173
183
  error: null,
174
184
  fetchNextPage: vi.fn(),
175
185
  hasNextPage: false,
@@ -180,21 +190,26 @@ vi.mock('./hooks', () => ({
180
190
  }),
181
191
  useMultiGroupProducts: () => ({
182
192
  data: [],
183
- error: null,
193
+ error: testState.groupProductsError,
194
+ refetch: testState.refetchGroupProducts,
184
195
  isLoading: false,
185
196
  }),
186
197
  useProducts: () => ({
187
198
  data: testState.products,
188
- error: null,
199
+ error: testState.productsError,
200
+ refetch: testState.refetchProducts,
189
201
  isLoading: false,
190
202
  }),
191
203
  useSubscriptionInvoiceContext: () => ({
192
204
  data: { attendance: [], latestInvoices: [] },
193
- error: null,
205
+ error: testState.contextError,
206
+ refetch: testState.refetchContext,
194
207
  isLoading: false,
195
208
  }),
196
209
  useUserGroups: () => ({
197
210
  data: testState.userGroups,
211
+ error: testState.userGroupsError,
212
+ refetch: testState.refetchUserGroups,
198
213
  isLoading: false,
199
214
  }),
200
215
  useUserLinkedPromotions: () => ({ data: [] }),
@@ -272,6 +287,15 @@ describe('SubscriptionInvoice checkout defaults', () => {
272
287
  testState.useCategories.mockClear();
273
288
  testState.useWallets.mockClear();
274
289
  testState.productSelectionInjected = false;
290
+ testState.productsError = null;
291
+ testState.userGroupsError = null;
292
+ testState.groupProductsError = null;
293
+ testState.contextError = null;
294
+ testState.refetchUserGroups.mockClear();
295
+ testState.refetchGroupProducts.mockClear();
296
+ testState.refetchContext.mockClear();
297
+ testState.refetchProducts.mockClear();
298
+ testState.refetchUsers.mockClear();
275
299
  testState.month = '2026-07';
276
300
  testState.setMonth.mockClear();
277
301
  testState.userGroups[0]!.workspace_user_groups.ending_date = null;
@@ -281,6 +305,49 @@ describe('SubscriptionInvoice checkout defaults', () => {
281
305
  testState.categories = [];
282
306
  });
283
307
 
308
+ it('shows a retry when product loading fails and reopens the form after recovery', async () => {
309
+ testState.productsError = new Error('Timed out');
310
+ const { rerender } = renderSubscriptionInvoice();
311
+ expect(screen.getByRole('alert')).toHaveTextContent(
312
+ 'ws-invoices.load_failed'
313
+ );
314
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
315
+ expect(testState.refetchProducts).toHaveBeenCalledOnce();
316
+ expect(testState.refetchUsers).toHaveBeenCalledOnce();
317
+
318
+ testState.productsError = null;
319
+ rerender(
320
+ <SubscriptionInvoice wsId="ws-1" createMultipleInvoices={false} />
321
+ );
322
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
323
+ await waitFor(() =>
324
+ expect(testState.InvoicePaymentSettings).toHaveBeenCalled()
325
+ );
326
+ });
327
+
328
+ it.each(['userGroupsError', 'groupProductsError', 'contextError'] as const)(
329
+ 'recovers from a failed subscription %s read',
330
+ async (key) => {
331
+ testState[key] = new Error('Timed out');
332
+ const { rerender } = renderSubscriptionInvoice();
333
+ expect(screen.getByRole('alert')).toHaveTextContent(
334
+ 'ws-invoices.load_failed'
335
+ );
336
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
337
+ expect(testState.refetchUserGroups).toHaveBeenCalledOnce();
338
+ expect(testState.refetchGroupProducts).toHaveBeenCalledOnce();
339
+ expect(testState.refetchContext).toHaveBeenCalledOnce();
340
+ testState[key] = null;
341
+ rerender(
342
+ <SubscriptionInvoice wsId="ws-1" createMultipleInvoices={false} />
343
+ );
344
+ await waitFor(() =>
345
+ expect(testState.InvoicePaymentSettings).toHaveBeenCalled()
346
+ );
347
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
348
+ }
349
+ );
350
+
284
351
  it('preloads payment options from defaults and keeps default IDs while options load', async () => {
285
352
  const { rerender } = renderSubscriptionInvoice();
286
353
 
@@ -21,15 +21,13 @@ import {
21
21
  } from 'react';
22
22
  import { useDebounce } from '../../../../hooks/use-debounce';
23
23
  import { useFinanceHref } from '../finance-route-context';
24
- import {
25
- type FinancePermissionRequestUser,
26
- FinancePermissionWarningDialog,
27
- } from '../shared/finance-permission-warning-dialog';
24
+ import { FinancePermissionWarningDialog } from '../shared/finance-permission-warning-dialog';
28
25
  import { useFinanceConfidentialVisibility } from '../shared/use-finance-confidential-visibility';
29
26
  import { InvoiceBlockedState } from './components/invoice-blocked-state';
30
27
  import { InvoiceCheckoutSummary } from './components/invoice-checkout-summary';
31
28
  import { InvoiceContentEditor } from './components/invoice-content-editor';
32
29
  import { InvoiceCustomerSelectCard } from './components/invoice-customer-select-card';
30
+ import { InvoiceDataState } from './components/invoice-data-state';
33
31
  import { InvoicePaymentSettings } from './components/invoice-payment-settings';
34
32
  import {
35
33
  InvoiceProductsPermissionWarning,
@@ -63,6 +61,7 @@ import { useSubscriptionInvoiceContent } from './hooks/use-subscription-invoice-
63
61
  import { createSubscriptionInvoiceWithInternalApi } from './internal-api';
64
62
  import { formatInvoiceRecalculationDescription } from './invoice-visibility-format';
65
63
  import { ProductSelection } from './product-selection';
64
+ import type { SubscriptionInvoiceProps } from './subscription-invoice-props';
66
65
  import type { SelectedProductItem } from './types';
67
66
  import {
68
67
  formatCoverageRangeLabel,
@@ -82,25 +81,6 @@ import {
82
81
  resolveSubscriptionInvoiceCategoryId,
83
82
  } from './utils';
84
83
 
85
- interface Props {
86
- wsId: string;
87
- prefillQuantity?: number | null;
88
- suggestedTotal?: number | null;
89
- createMultipleInvoices: boolean;
90
- printAfterCreate?: boolean;
91
- downloadImageAfterCreate?: boolean;
92
- defaultWalletId?: string;
93
- defaultCategoryId?: string;
94
- defaultCurrency?: string;
95
- workspaceTimezone?: string | null;
96
- canChangeFinanceWallets?: boolean;
97
- canSetFinanceWalletsOnCreate?: boolean;
98
- canReadInvoiceProducts?: boolean;
99
- canReadInvoiceProductStock?: boolean;
100
- canReadGroupLinkedProducts?: boolean;
101
- permissionRequestUser?: FinancePermissionRequestUser | null;
102
- }
103
-
104
84
  export function SubscriptionInvoice({
105
85
  wsId,
106
86
  prefillQuantity,
@@ -118,7 +98,7 @@ export function SubscriptionInvoice({
118
98
  canReadInvoiceProductStock = true,
119
99
  canReadGroupLinkedProducts = true,
120
100
  permissionRequestUser,
121
- }: Props) {
101
+ }: SubscriptionInvoiceProps) {
122
102
  const t = useTranslations();
123
103
  const locale = useLocale();
124
104
  const defaultCurrency = resolveSupportedCurrency(rawDefaultCurrency);
@@ -128,7 +108,6 @@ export function SubscriptionInvoice({
128
108
  const queryClient = useQueryClient();
129
109
  const financeHref = useFinanceHref();
130
110
 
131
- // URL state using nuqs
132
111
  const [selectedUserId, setSelectedUserId] = useQueryState('user_id', {
133
112
  defaultValue: '',
134
113
  shallow: false,
@@ -170,12 +149,12 @@ export function SubscriptionInvoice({
170
149
  [selectedGroupIds]
171
150
  );
172
151
 
173
- // Data queries
174
152
  const {
175
153
  customers: users,
176
154
  selectedUser,
177
155
  isLoading: usersLoading,
178
156
  error: usersError,
157
+ refetch: refetchUsers,
179
158
  hasNextPage: hasMoreCustomers,
180
159
  fetchNextPage: fetchMoreCustomers,
181
160
  isFetching: isFetchingCustomers,
@@ -184,6 +163,7 @@ export function SubscriptionInvoice({
184
163
  const {
185
164
  data: products = [],
186
165
  error: productsError,
166
+ refetch: refetchProducts,
187
167
  isLoading: productsLoading,
188
168
  } = useProducts(wsId, {
189
169
  enabled: canReadInvoiceProducts && hasSelectedGroups,
@@ -222,7 +202,6 @@ export function SubscriptionInvoice({
222
202
  [selectedGroupIds, blockedGroupIds]
223
203
  );
224
204
 
225
- // State management
226
205
  const [selectedWalletId, setSelectedWalletId] = useState<string>(
227
206
  defaultWalletId || ''
228
207
  );
@@ -268,14 +247,16 @@ export function SubscriptionInvoice({
268
247
  // Track previous user ID to detect user changes (skip initial mount for reset)
269
248
  const prevUserIdRef = useRef<string | null>(null);
270
249
 
271
- // Subscription-specific queries
272
- const { data: userGroups = [], isLoading: userGroupsLoading } = useUserGroups(
273
- wsId,
274
- selectedUserId
275
- );
250
+ const {
251
+ data: userGroups = [],
252
+ isLoading: userGroupsLoading,
253
+ error: userGroupsError,
254
+ refetch: refetchUserGroups,
255
+ } = useUserGroups(wsId, selectedUserId);
276
256
  const {
277
257
  data: groupProducts = [],
278
258
  error: groupProductsError,
259
+ refetch: refetchGroupProducts,
279
260
  isLoading: groupProductsLoading,
280
261
  } = useMultiGroupProducts(wsId, selectedGroupIds, {
281
262
  enabled: canReadGroupLinkedProducts,
@@ -341,6 +322,7 @@ export function SubscriptionInvoice({
341
322
  data: subscriptionInvoiceContext,
342
323
  isLoading: subscriptionInvoiceContextLoading,
343
324
  error: subscriptionInvoiceContextError,
325
+ refetch: refetchSubscriptionContext,
344
326
  } = useSubscriptionInvoiceContext(
345
327
  wsId,
346
328
  selectedUserId,
@@ -547,7 +529,6 @@ export function SubscriptionInvoice({
547
529
  return map;
548
530
  }, [referralDiscountRows]);
549
531
 
550
- // Use hooks for logic
551
532
  useSubscriptionAutoSelection({
552
533
  enabled: true,
553
534
  selectedGroupIds,
@@ -897,16 +878,37 @@ export function SubscriptionInvoice({
897
878
  }
898
879
  };
899
880
 
900
- if (isLoadingData) {
881
+ if (
882
+ isLoadingData ||
883
+ usersError ||
884
+ (selectedUserId && userGroupsError) ||
885
+ (hasSelectedGroups &&
886
+ canReadGroupLinkedProducts &&
887
+ groupProductsError &&
888
+ !isPermissionRequestError(groupProductsError)) ||
889
+ (selectedUserId &&
890
+ hasSelectedGroups &&
891
+ subscriptionInvoiceContextError &&
892
+ !isPermissionRequestError(subscriptionInvoiceContextError)) ||
893
+ (canReadInvoiceProducts &&
894
+ hasSelectedGroups &&
895
+ productsError &&
896
+ !isPermissionRequestError(productsError))
897
+ ) {
901
898
  return (
902
- <div className="flex items-center justify-center py-8">
903
- <div className="flex items-center gap-2">
904
- <Loader2 className="h-4 w-4 animate-spin" />
905
- <p className="text-muted-foreground text-sm">
906
- {t('ws-invoices.loading')}
907
- </p>
908
- </div>
909
- </div>
899
+ <InvoiceDataState
900
+ loading={isLoadingData || isLoadingSubscriptionData}
901
+ onRetry={() => {
902
+ void refetchUsers();
903
+ if (selectedUserId) void refetchUserGroups();
904
+ if (selectedUserId && hasSelectedGroups)
905
+ void refetchSubscriptionContext();
906
+ if (hasSelectedGroups && canReadGroupLinkedProducts)
907
+ void refetchGroupProducts();
908
+ if (canReadInvoiceProducts && hasSelectedGroups)
909
+ void refetchProducts();
910
+ }}
911
+ />
910
912
  );
911
913
  }
912
914
 
@@ -929,9 +931,6 @@ export function SubscriptionInvoice({
929
931
  isFetchingNextPage={isFetchingMoreCustomers}
930
932
  hasNextPage={hasMoreCustomers}
931
933
  onLoadMore={() => void fetchMoreCustomers()}
932
- errorMessage={
933
- usersError instanceof Error ? usersError.message : undefined
934
- }
935
934
  emptyMessage={t('ws-invoices.no_customers_found')}
936
935
  searchValue={customerSearch}
937
936
  onSearchChange={setCustomerSearch}
@@ -1,13 +1,4 @@
1
- import type { LegendPayload } from 'recharts';
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: (entry: LegendPayload) => void;
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
- <ChartContainer config={chartConfig} className="h-80 w-full">
47
- <BarChart data={chartData}>
48
- <CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.3} />
49
- <XAxis
50
- dataKey="period"
51
- tickLine={false}
52
- axisLine={false}
53
- tickFormatter={(value) =>
54
- formatCategoryBreakdownXAxisTick(String(value), interval, locale)
55
- }
56
- tick={{ fill: 'hsl(var(--foreground))', opacity: 0.7 }}
57
- />
58
- <YAxis
59
- tickLine={false}
60
- axisLine={false}
61
- tickFormatter={(value) =>
62
- typeof value === 'number' ? formatCompactValue(value) : value
63
- }
64
- tick={{ fill: 'hsl(var(--foreground))', opacity: 0.7 }}
65
- width={60}
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
- radius={[0, 0, 0, 0]}
111
- maxBarSize={50}
112
- hide={hiddenCategories.has(category.name)}
59
+ tick={{ fill: 'var(--foreground)', opacity: 0.7 }}
60
+ width={60}
113
61
  />
114
- ))}
115
- </BarChart>
116
- </ChartContainer>
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 sm:flex-row sm:items-center sm:justify-between">
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 { LegendPayload } from 'recharts';
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: (entry: LegendPayload) => void;
7
- payload?: readonly LegendPayload[];
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
- payload,
15
+ formatValue,
14
16
  }: CategoryBreakdownLegendProps) {
15
- if (!payload) return null;
16
-
17
17
  return (
18
- <div className="flex flex-wrap justify-center gap-3 pt-5">
19
- {payload.map((entry) => {
20
- const isHidden = hiddenCategories.has(entry.value as string);
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={String(entry.value)}
23
+ key={category.key}
24
24
  type="button"
25
- onClick={() => onToggleCategory(entry)}
25
+ aria-pressed={!hidden}
26
+ aria-label={`${category.name} ${formatValue(category.total)}`}
27
+ onClick={() => onToggleCategory(category.key)}
26
28
  className={cn(
27
- 'flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-sm transition-all hover:bg-accent',
28
- isHidden && 'opacity-40'
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={cn(isHidden && 'line-through')}
40
- style={{ color: 'hsl(var(--foreground))' }}
41
- >
42
- {entry.value}
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
  );