@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.
- package/CHANGELOG.md +57 -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-markdown.tsx +17 -2
- 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/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/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/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.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-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.ts +1 -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-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 +44 -45
- 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/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
|
@@ -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:
|
|
193
|
+
error: testState.groupProductsError,
|
|
194
|
+
refetch: testState.refetchGroupProducts,
|
|
184
195
|
isLoading: false,
|
|
185
196
|
}),
|
|
186
197
|
useProducts: () => ({
|
|
187
198
|
data: testState.products,
|
|
188
|
-
error:
|
|
199
|
+
error: testState.productsError,
|
|
200
|
+
refetch: testState.refetchProducts,
|
|
189
201
|
isLoading: false,
|
|
190
202
|
}),
|
|
191
203
|
useSubscriptionInvoiceContext: () => ({
|
|
192
204
|
data: { attendance: [], latestInvoices: [] },
|
|
193
|
-
error:
|
|
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
|
-
}:
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 (
|
|
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
|
-
<
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
|
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
|
);
|