@tuturuuu/ui 0.33.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +5 -5
  3. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  4. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  5. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  6. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  7. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  8. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  9. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  10. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  11. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  12. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  13. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  14. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  15. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  16. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  17. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  18. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  19. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  20. package/src/components/ui/finance/invoices/query-invalidation.ts +2 -0
  21. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  22. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +3 -6
  24. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  25. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  26. package/src/components/ui/finance/invoices/utils.ts +48 -106
  27. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  28. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  29. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  30. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
@@ -0,0 +1,255 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import type { Product, SelectedProductItem, UserGroupProducts } from '../types';
5
+ import type { UserGroup } from '../utils';
6
+ import { useSubscriptionAutoSelection } from './use-subscription-auto-selection';
7
+
8
+ const translate = (key: string) => key;
9
+ vi.mock('next-intl', () => ({ useTranslations: () => translate }));
10
+ vi.mock('@tuturuuu/ui/sonner', () => ({ toast: vi.fn() }));
11
+
12
+ const inventory = {
13
+ unit_id: 'unit',
14
+ warehouse_id: 'warehouse',
15
+ amount: null,
16
+ min_amount: 0,
17
+ price: 100,
18
+ unit_name: 'Session',
19
+ warehouse_name: 'Main',
20
+ };
21
+ const products: Product[] = [
22
+ {
23
+ id: 'tuition',
24
+ name: 'Tuition',
25
+ manufacturer: null,
26
+ description: null,
27
+ usage: null,
28
+ category: null,
29
+ category_id: 'cat',
30
+ ws_id: 'ws',
31
+ created_at: null,
32
+ inventory: [inventory],
33
+ },
34
+ ];
35
+ const groupProducts: UserGroupProducts[] = [
36
+ {
37
+ group_id: 'class-a',
38
+ workspace_products: {
39
+ id: 'tuition',
40
+ name: 'Tuition',
41
+ product_categories: { name: null },
42
+ },
43
+ inventory_units: { id: 'unit', name: 'Session' },
44
+ warehouse_id: 'warehouse',
45
+ },
46
+ ];
47
+ const userGroups: UserGroup[] = [
48
+ {
49
+ workspace_user_groups: {
50
+ id: 'class-a',
51
+ name: 'Class A',
52
+ starting_date: '2026-01-01',
53
+ ending_date: null,
54
+ sessions: ['2026-01-05', '2026-01-12'],
55
+ } as NonNullable<UserGroup['workspace_user_groups']>,
56
+ },
57
+ ];
58
+ const baseProps = {
59
+ enabled: true,
60
+ selectedGroupIds: ['class-a'],
61
+ selectedMonth: '2026-01',
62
+ groupProducts,
63
+ products,
64
+ userGroups,
65
+ useAttendanceBased: false,
66
+ userAttendance: [],
67
+ latestSubscriptionInvoices: [],
68
+ };
69
+
70
+ type Props = Omit<
71
+ Parameters<typeof useSubscriptionAutoSelection>[0],
72
+ 'onSelectedProductsChange'
73
+ >;
74
+ function useSelection(props: Props) {
75
+ const [selected, setSelected] = useState<SelectedProductItem[]>([]);
76
+ useSubscriptionAutoSelection({
77
+ ...props,
78
+ onSelectedProductsChange: setSelected,
79
+ });
80
+ return selected;
81
+ }
82
+
83
+ describe('subscription selection reflects current billable sessions', () => {
84
+ it('clears prior charges when switching to a month with no sessions', () => {
85
+ const { result, rerender } = renderHook(useSelection, {
86
+ initialProps: baseProps as Props,
87
+ });
88
+ expect(result.current.map((row) => row.quantity)).toEqual([2]);
89
+ rerender({ ...baseProps, selectedMonth: '2026-02' });
90
+ expect(result.current).toEqual([]);
91
+ });
92
+
93
+ it('clears prior charges after payment coverage makes the month fully paid', () => {
94
+ const { result, rerender } = renderHook(useSelection, {
95
+ initialProps: baseProps as Props,
96
+ });
97
+ expect(result.current).toHaveLength(1);
98
+ rerender({
99
+ ...baseProps,
100
+ latestSubscriptionInvoices: [
101
+ { group_id: 'class-a', valid_until: '2026-02-01' },
102
+ ],
103
+ });
104
+ expect(result.current).toEqual([]);
105
+ });
106
+
107
+ it('keeps charges for an earlier gap when a later month was explicitly paid', () => {
108
+ const { result } = renderHook(useSelection, {
109
+ initialProps: {
110
+ ...baseProps,
111
+ latestSubscriptionInvoices: [
112
+ {
113
+ group_id: 'class-a',
114
+ valid_until: '2026-03-01',
115
+ covered_months: ['2026-02-01'],
116
+ },
117
+ ],
118
+ } as Props,
119
+ });
120
+ expect(result.current.map((row) => row.quantity)).toEqual([2]);
121
+ });
122
+
123
+ it('clears charges for an explicitly paid month without relying on expiry', () => {
124
+ const { result } = renderHook(useSelection, {
125
+ initialProps: {
126
+ ...baseProps,
127
+ latestSubscriptionInvoices: [
128
+ { group_id: 'class-a', covered_months: ['2026-01-01'] },
129
+ ],
130
+ } as Props,
131
+ });
132
+ expect(result.current).toEqual([]);
133
+ });
134
+
135
+ it('clears charges when linked inventory becomes unavailable', () => {
136
+ const { result, rerender } = renderHook(useSelection, {
137
+ initialProps: baseProps as Props,
138
+ });
139
+ expect(result.current).toHaveLength(1);
140
+ rerender({
141
+ ...baseProps,
142
+ products: [{ ...products[0]!, inventory: [{ ...inventory, amount: 0 }] }],
143
+ });
144
+ expect(result.current).toEqual([]);
145
+ });
146
+
147
+ it('repopulates charges when switching back to a billable month', () => {
148
+ const { result, rerender } = renderHook(useSelection, {
149
+ initialProps: { ...baseProps, selectedMonth: '2026-02' } as Props,
150
+ });
151
+ expect(result.current).toEqual([]);
152
+ rerender(baseProps);
153
+ expect(result.current.map((row) => row.quantity)).toEqual([2]);
154
+ });
155
+ });
156
+
157
+ describe('subscription class and inventory updates', () => {
158
+ it('updates the selected price when inventory pricing refreshes', () => {
159
+ const { result, rerender } = renderHook(useSelection, {
160
+ initialProps: baseProps as Props,
161
+ });
162
+ expect(result.current[0]?.inventory.price).toBe(100);
163
+ rerender({
164
+ ...baseProps,
165
+ products: [
166
+ { ...products[0]!, inventory: [{ ...inventory, price: 150 }] },
167
+ ],
168
+ });
169
+ expect(result.current[0]?.inventory.price).toBe(150);
170
+ });
171
+
172
+ it('keeps the unpaid class when another selected class is covered', () => {
173
+ const otherGroup = {
174
+ workspace_user_groups: {
175
+ ...userGroups[0]!.workspace_user_groups!,
176
+ id: 'class-b',
177
+ },
178
+ };
179
+ const props: Props = {
180
+ ...baseProps,
181
+ selectedGroupIds: ['class-a', 'class-b'],
182
+ userGroups: [...userGroups, otherGroup],
183
+ groupProducts: [
184
+ ...groupProducts,
185
+ { ...groupProducts[0]!, group_id: 'class-b' },
186
+ ],
187
+ };
188
+ const { result, rerender } = renderHook(useSelection, {
189
+ initialProps: props,
190
+ });
191
+ expect(result.current.map((row) => row.quantity)).toEqual([2, 2]);
192
+ rerender({
193
+ ...props,
194
+ latestSubscriptionInvoices: [
195
+ { group_id: 'class-a', valid_until: '2026-02-01' },
196
+ ],
197
+ });
198
+ expect(result.current.map((row) => row.quantity)).toEqual([2]);
199
+ });
200
+
201
+ it('counts only present and late attendance in attendance-based billing', () => {
202
+ const props: Props = {
203
+ ...baseProps,
204
+ useAttendanceBased: true,
205
+ userAttendance: [
206
+ { group_id: 'class-a', date: '2026-01-05', status: 'PRESENT' },
207
+ { group_id: 'class-a', date: '2026-01-12', status: 'LATE' },
208
+ { group_id: 'class-a', date: '2026-01-19', status: 'ABSENT' },
209
+ { group_id: 'class-b', date: '2026-01-05', status: 'PRESENT' },
210
+ ],
211
+ };
212
+ const { result, rerender } = renderHook(useSelection, {
213
+ initialProps: props,
214
+ });
215
+ expect(result.current.map((row) => row.quantity)).toEqual([2]);
216
+ rerender({ ...props, userAttendance: [] });
217
+ expect(result.current).toEqual([]);
218
+ });
219
+
220
+ it('respects finite stock and updates when stock recovers', () => {
221
+ const props: Props = {
222
+ ...baseProps,
223
+ products: [{ ...products[0]!, inventory: [{ ...inventory, amount: 1 }] }],
224
+ };
225
+ const { result, rerender } = renderHook(useSelection, {
226
+ initialProps: props,
227
+ });
228
+ expect(result.current.map((row) => row.quantity)).toEqual([1]);
229
+ rerender(baseProps);
230
+ expect(result.current.map((row) => row.quantity)).toEqual([2]);
231
+ });
232
+
233
+ it('does not populate selection while auto-selection is disabled', () => {
234
+ const { result } = renderHook(useSelection, {
235
+ initialProps: { ...baseProps, enabled: false },
236
+ });
237
+ expect(result.current).toEqual([]);
238
+ });
239
+ });
240
+
241
+ describe('prefilled subscription quantities', () => {
242
+ it('retains an explicit initial quantity until billing inputs change', () => {
243
+ const props: Props = { ...baseProps, prefillAmount: 5 };
244
+ const { result, rerender } = renderHook(useSelection, {
245
+ initialProps: props,
246
+ });
247
+ expect(result.current.map((row) => row.quantity)).toEqual([5]);
248
+ rerender(props);
249
+ expect(result.current.map((row) => row.quantity)).toEqual([5]);
250
+ rerender({ ...props, selectedMonth: '2026-02' });
251
+ expect(result.current).toEqual([]);
252
+ rerender(props);
253
+ expect(result.current.map((row) => row.quantity)).toEqual([2]);
254
+ });
255
+ });
@@ -14,7 +14,7 @@ import type {
14
14
  SelectedProductItem,
15
15
  UserGroupProducts,
16
16
  } from '../types';
17
- import type { UserGroup } from '../utils';
17
+ import type { SubscriptionCoverageInvoice, UserGroup } from '../utils';
18
18
  import {
19
19
  getBillableQuantityMapForGroupsRange,
20
20
  getEffectiveDays,
@@ -31,11 +31,7 @@ interface UseSubscriptionAutoSelectionProps {
31
31
  userGroups: UserGroup[];
32
32
  useAttendanceBased: boolean;
33
33
  userAttendance: { status: string; date: string; group_id?: string }[];
34
- latestSubscriptionInvoices: {
35
- group_id?: string;
36
- valid_until?: string | null;
37
- created_at?: string | null;
38
- }[];
34
+ latestSubscriptionInvoices: SubscriptionCoverageInvoice[];
39
35
  onSelectedProductsChange: Dispatch<SetStateAction<SelectedProductItem[]>>;
40
36
  prepaidMonthCount?: number;
41
37
  workspaceTimezone?: string | null;
@@ -129,7 +125,7 @@ const areSelectedProductsEqual = (
129
125
  if (current.length !== next.length) return false;
130
126
 
131
127
  const toKey = (item: SelectedProductItem) =>
132
- `${item.product.id}|${item.inventory.unit_id}|${item.inventory.warehouse_id}|${item.quantity}`;
128
+ `${item.product.id}|${item.inventory.unit_id}|${item.inventory.warehouse_id}|${item.inventory.price}|${item.inventory.amount}|${item.quantity}`;
133
129
 
134
130
  const buildCounts = (items: SelectedProductItem[]) => {
135
131
  const counts = new Map<string, number>();
@@ -155,11 +151,7 @@ const areSelectedProductsEqual = (
155
151
  const computeGroupAttendanceDaysMap = (
156
152
  selectedGroupIds: string[],
157
153
  userGroups: UserGroup[],
158
- latestSubscriptionInvoices: {
159
- group_id?: string;
160
- valid_until?: string | null;
161
- created_at?: string | null;
162
- }[],
154
+ latestSubscriptionInvoices: SubscriptionCoverageInvoice[],
163
155
  selectedMonth: string,
164
156
  userAttendance: { status: string; date: string; group_id?: string }[],
165
157
  useAttendanceBased: boolean,
@@ -192,11 +184,7 @@ const computeFallbackAttendanceDays = ({
192
184
  userGroups,
193
185
  }: {
194
186
  groupAttendanceDaysMap: Record<string, number>;
195
- latestSubscriptionInvoices: {
196
- group_id?: string;
197
- valid_until?: string | null;
198
- created_at?: string | null;
199
- }[];
187
+ latestSubscriptionInvoices: SubscriptionCoverageInvoice[];
200
188
  prepaidMonthCount: number;
201
189
  selectedGroupIds: string[];
202
190
  selectedMonth: string;
@@ -313,9 +301,7 @@ export function useSubscriptionAutoSelection({
313
301
  const isGroupChanged = previousGroupIdRef.current !== currentGroupIdsKey;
314
302
  previousGroupIdRef.current = currentGroupIdsKey;
315
303
 
316
- if (!isGroupChanged) return;
317
-
318
- fallbackToastShownRef.current = false;
304
+ if (isGroupChanged) fallbackToastShownRef.current = false;
319
305
 
320
306
  const shouldUsePrefill =
321
307
  prepaidMonthCount === 1 &&
@@ -356,76 +342,4 @@ export function useSubscriptionAutoSelection({
356
342
  groupAttendanceDaysMap,
357
343
  prepaidMonthCount,
358
344
  ]);
359
-
360
- // Auto-add group products based on attendance when group is selected
361
- useEffect(() => {
362
- if (
363
- !enabled ||
364
- sortedSelectedGroupIds.length === 0 ||
365
- groupProducts.length === 0
366
- ) {
367
- return;
368
- }
369
-
370
- if (
371
- prepaidMonthCount === 1 &&
372
- prefillAmount != null &&
373
- !initialPrefillUsedRef.current
374
- ) {
375
- return;
376
- }
377
-
378
- const attendanceDays = computeFallbackAttendanceDays({
379
- groupAttendanceDaysMap,
380
- latestSubscriptionInvoices,
381
- prepaidMonthCount,
382
- selectedGroupIds: sortedSelectedGroupIds,
383
- selectedMonth,
384
- useAttendanceBased,
385
- userAttendance,
386
- userGroups,
387
- });
388
-
389
- if (attendanceDays === 0) return;
390
-
391
- const { autoSelected, fallbackTriggered } =
392
- buildAutoSelectedProductsForGroup(
393
- groupProducts,
394
- products,
395
- attendanceDays,
396
- groupAttendanceDaysMap
397
- );
398
-
399
- if (autoSelected.length === 0) return;
400
-
401
- onSelectedProductsChange((prev) =>
402
- areSelectedProductsEqual(prev, autoSelected) ? prev : autoSelected
403
- );
404
-
405
- if (fallbackTriggered && !fallbackToastShownRef.current) {
406
- toast(
407
- t('ws-invoices.inventory_fallback_used', {
408
- default:
409
- 'Some items used fallback warehouses due to missing preference.',
410
- })
411
- );
412
- fallbackToastShownRef.current = true;
413
- }
414
- }, [
415
- enabled,
416
- sortedSelectedGroupIds,
417
- selectedMonth,
418
- userAttendance?.length,
419
- prefillAmount,
420
- groupProducts,
421
- userGroups,
422
- useAttendanceBased,
423
- t,
424
- userAttendance,
425
- latestSubscriptionInvoices,
426
- onSelectedProductsChange,
427
- products,
428
- groupAttendanceDaysMap,
429
- prepaidMonthCount,
430
- ]);
431
345
  }
@@ -0,0 +1,93 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { UserGroup } from '../utils';
4
+ import { useSubscriptionInvoiceContent } from './use-subscription-invoice-content';
5
+
6
+ const translate = Object.assign(
7
+ (key: string, values?: Record<string, unknown>) =>
8
+ values?.monthName ? String(values.monthName) : key,
9
+ { has: () => true }
10
+ );
11
+ vi.mock('next-intl', () => ({ useTranslations: () => translate }));
12
+ const base = {
13
+ enabled: true,
14
+ selectedGroupIds: ['g1'],
15
+ selectedMonth: '2026-01',
16
+ prepaidMonthCount: 3,
17
+ userGroups: [
18
+ {
19
+ workspace_user_groups: {
20
+ id: 'g1',
21
+ name: 'Class A',
22
+ sessions: [],
23
+ ws_id: 'ws',
24
+ archived: false,
25
+ cert_template: 'original',
26
+ created_at: null,
27
+ creator_id: null,
28
+ description: null,
29
+ ending_date: null,
30
+ is_course_published: false,
31
+ is_guest: false,
32
+ notes: null,
33
+ starting_date: '2026-01-01',
34
+ },
35
+ },
36
+ ] satisfies UserGroup[],
37
+ groupProducts: [],
38
+ subscriptionSelectedProducts: [],
39
+ userAttendance: [],
40
+ isSelectedMonthPaid: false,
41
+ locale: 'en',
42
+ onNotesChange: vi.fn(),
43
+ };
44
+ describe('subscription invoice month descriptions', () => {
45
+ it('lists separated unpaid months without claiming the paid month between them', () => {
46
+ const onContentChange = vi.fn();
47
+ renderHook(() =>
48
+ useSubscriptionInvoiceContent({
49
+ ...base,
50
+ onContentChange,
51
+ latestSubscriptionInvoices: [
52
+ {
53
+ group_id: 'g1',
54
+ valid_until: '2026-03-01',
55
+ covered_months: ['2026-02-01'],
56
+ },
57
+ ],
58
+ })
59
+ );
60
+ expect(onContentChange).toHaveBeenLastCalledWith(
61
+ 'January 2026, March 2026'
62
+ );
63
+ });
64
+ it('keeps a compact range for contiguous unpaid months', () => {
65
+ const onContentChange = vi.fn();
66
+ renderHook(() =>
67
+ useSubscriptionInvoiceContent({
68
+ ...base,
69
+ onContentChange,
70
+ latestSubscriptionInvoices: [],
71
+ })
72
+ );
73
+ expect(onContentChange).toHaveBeenLastCalledWith(
74
+ 'January 2026 - March 2026'
75
+ );
76
+ });
77
+ it('does not substitute a future expiry for the selected fully covered range', () => {
78
+ const onContentChange = vi.fn();
79
+ renderHook(() =>
80
+ useSubscriptionInvoiceContent({
81
+ ...base,
82
+ onContentChange,
83
+ isSelectedMonthPaid: true,
84
+ latestSubscriptionInvoices: [
85
+ { group_id: 'g1', valid_until: '2027-01-01' },
86
+ ],
87
+ })
88
+ );
89
+ expect(onContentChange).toHaveBeenLastCalledWith(
90
+ 'January 2026 - March 2026'
91
+ );
92
+ });
93
+ });
@@ -1,7 +1,7 @@
1
1
  import { useTranslations } from 'next-intl';
2
2
  import { useEffect, useRef } from 'react';
3
3
  import type { SelectedProductItem, UserGroupProducts } from '../types';
4
- import type { UserGroup } from '../utils';
4
+ import type { SubscriptionCoverageInvoice, UserGroup } from '../utils';
5
5
  import {
6
6
  formatCoverageRangeLabel,
7
7
  formatMonthLabel,
@@ -10,7 +10,6 @@ import {
10
10
  getBillableSessionsForGroupsInRange,
11
11
  getCoverageEndMonthValue,
12
12
  getCoverageMonths,
13
- getSubscriptionCoverageInvoiceForGroup,
14
13
  isSubscriptionMonthPaidForGroup,
15
14
  } from '../utils';
16
15
 
@@ -22,10 +21,7 @@ interface UseSubscriptionInvoiceContentProps {
22
21
  groupProducts: UserGroupProducts[];
23
22
  subscriptionSelectedProducts: SelectedProductItem[];
24
23
  userAttendance: { status: string; date: string; group_id?: string }[];
25
- latestSubscriptionInvoices: {
26
- group_id?: string;
27
- valid_until?: string | null;
28
- }[];
24
+ latestSubscriptionInvoices: SubscriptionCoverageInvoice[];
29
25
  isSelectedMonthPaid: boolean;
30
26
  locale: string;
31
27
  onContentChange: (content: string) => void;
@@ -90,10 +86,6 @@ export function useSubscriptionInvoiceContent({
90
86
  if (!group) return;
91
87
 
92
88
  const groupName = group.workspace_user_groups?.name || 'Unknown Group';
93
- const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
94
- latestSubscriptionInvoices,
95
- groupId
96
- );
97
89
  const unpaidMonths = coverageMonths.filter(
98
90
  (month) =>
99
91
  !isSubscriptionMonthPaidForGroup(
@@ -103,15 +95,19 @@ export function useSubscriptionInvoiceContent({
103
95
  )
104
96
  );
105
97
 
106
- const startMonth =
107
- unpaidMonths[0] ??
108
- latestInvoice?.valid_until?.slice(0, 7) ??
109
- selectedMonth;
98
+ const startMonth = unpaidMonths[0] ?? selectedMonth;
110
99
  const endMonth =
111
100
  unpaidMonths[unpaidMonths.length - 1] ?? coverageEndMonth;
112
101
 
113
102
  let rangeLabel = '';
114
- if (startMonth && startMonth < endMonth) {
103
+ const hasGaps = unpaidMonths.some(
104
+ (month, index) =>
105
+ index > 0 &&
106
+ getCoverageMonths(unpaidMonths[index - 1]!, 2)[1] !== month
107
+ );
108
+ if (hasGaps) {
109
+ rangeLabel = unpaidMonths.map(formatMonth).join(', ');
110
+ } else if (startMonth && startMonth < endMonth) {
115
111
  // Range from startMonth to endMonth
116
112
  rangeLabel = `${formatMonth(startMonth)} - ${formatMonth(endMonth)}`;
117
113
  } else {
@@ -4,12 +4,14 @@ import { useTranslations } from 'next-intl';
4
4
  import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
5
5
  import { useEffect, useMemo, useState } from 'react';
6
6
  import { Card, CardContent } from '../../card';
7
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../tabs';
7
8
  import {
8
9
  InvoiceTotalsChart,
9
10
  type InvoiceTotalsChartProps,
10
11
  InvoiceTotalsChartSkeleton,
11
12
  } from './charts/invoice-totals-chart';
12
13
  import { useInvoiceAnalytics } from './hooks/use-invoice-analytics';
14
+ import { SubscriptionPaymentAnalytics } from './subscription-payment-analytics';
13
15
 
14
16
  /**
15
17
  * weekStartsOn values (JavaScript convention, matching useCalendarPreferences):
@@ -31,7 +33,7 @@ interface InvoiceAnalyticsProps {
31
33
  currency?: string;
32
34
  }
33
35
 
34
- export function InvoiceAnalytics({
36
+ function InvoiceOverviewAnalytics({
35
37
  wsId,
36
38
  className,
37
39
  weekStartsOn = 1,
@@ -165,3 +167,24 @@ export function InvoiceAnalytics({
165
167
 
166
168
  return <InvoiceTotalsChart {...chartProps} />;
167
169
  }
170
+
171
+ export function InvoiceAnalytics(props: InvoiceAnalyticsProps) {
172
+ const t = useTranslations('invoice-subscriptions');
173
+ return (
174
+ <Tabs defaultValue="overview" className={props.className}>
175
+ <TabsList className="mb-3">
176
+ <TabsTrigger value="overview">{t('overview')}</TabsTrigger>
177
+ <TabsTrigger value="subscriptions">{t('title')}</TabsTrigger>
178
+ </TabsList>
179
+ <TabsContent value="subscriptions">
180
+ <SubscriptionPaymentAnalytics
181
+ wsId={props.wsId}
182
+ currency={props.currency}
183
+ />
184
+ </TabsContent>
185
+ <TabsContent value="overview">
186
+ <InvoiceOverviewAnalytics {...props} className={undefined} />
187
+ </TabsContent>
188
+ </Tabs>
189
+ );
190
+ }
@@ -0,0 +1,28 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import { expect, it } from 'vitest';
3
+ import { invalidateInvoiceMutationQueries } from './query-invalidation';
4
+
5
+ it('refreshes coverage and analytics after invoice changes without invalidating other workspaces', async () => {
6
+ const client = new QueryClient();
7
+ const roots = [
8
+ 'invoice-subscription-analytics',
9
+ 'subscription-invoice-context',
10
+ 'invoice-analytics',
11
+ 'pending-invoices',
12
+ ];
13
+ for (const root of roots)
14
+ for (const ws of ['ws1', 'ws2'])
15
+ client.setQueryData([root, ws, 'filters'], { value: 1 });
16
+ client.setQueryData(['unrelated', 'ws1'], { value: 1 });
17
+ await invalidateInvoiceMutationQueries(client, 'ws1');
18
+ for (const root of roots) {
19
+ expect(client.getQueryState([root, 'ws1', 'filters'])?.isInvalidated).toBe(
20
+ true
21
+ );
22
+ expect(client.getQueryState([root, 'ws2', 'filters'])?.isInvalidated).toBe(
23
+ false
24
+ );
25
+ }
26
+ expect(client.getQueryState(['unrelated', 'ws1'])?.isInvalidated).toBe(false);
27
+ client.clear();
28
+ });
@@ -12,6 +12,7 @@ const INVOICE_MUTATION_QUERY_ROOTS = new Set([
12
12
  'infinite-user-invoices',
13
13
  'invoice-analytics',
14
14
  'invoice-history',
15
+ 'invoice-subscription-analytics',
15
16
  'monthly-chart',
16
17
  'monthly-closing-balance',
17
18
  'monthly-opening-balance',
@@ -20,6 +21,7 @@ const INVOICE_MUTATION_QUERY_ROOTS = new Set([
20
21
  'pending-invoices-current-month',
21
22
  'promotions',
22
23
  'spending_trends',
24
+ 'subscription-invoice-context',
23
25
  'user-invoices',
24
26
  'user-linked-promotions',
25
27
  'user-referral-discounts',