@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,74 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ isSubscriptionMonthCoveredByInvoice as covered,
4
+ isSubscriptionMonthPaidForGroup as paid,
5
+ isSubscriptionRangePaidForGroup as range,
6
+ type SubscriptionCoverageInvoice,
7
+ } from './subscription-coverage';
8
+ import { getGroupPaymentStatus, type WorkspaceUserGroup } from './utils';
9
+
10
+ const explicit: SubscriptionCoverageInvoice = {
11
+ group_id: 'g1',
12
+ valid_until: '2026-04-01T00:00:00Z',
13
+ covered_months: ['2026-01-01', '2026-03-01'],
14
+ };
15
+ describe('explicit tuition coverage', () => {
16
+ afterEach(() => vi.useRealTimers());
17
+ it.each(['2026-01', '2026-03'])('covers recorded month %s', (month) =>
18
+ expect(covered(month, explicit)).toBe(true)
19
+ );
20
+ it.each(['2025-12', '2026-02', '2026-04'])(
21
+ 'does not infer unrecorded month %s from valid_until',
22
+ (month) => expect(covered(month, explicit)).toBe(false)
23
+ );
24
+ it('uses months even without a compatibility expiry', () =>
25
+ expect(covered('2026-01', { covered_months: ['2026-01-01'] })).toBe(true));
26
+ it('keeps null legacy coverage semantics', () => {
27
+ const legacy = { valid_until: '2026-03-01', covered_months: null };
28
+ expect(covered('2026-02', legacy)).toBe(true);
29
+ expect(covered('2026-03', legacy)).toBe(false);
30
+ });
31
+ it('treats empty explicit coverage as empty rather than legacy', () =>
32
+ expect(covered('2026-01', { ...explicit, covered_months: [] })).toBe(
33
+ false
34
+ ));
35
+ it('isolates groups', () =>
36
+ expect(paid('g2', '2026-01', [explicit])).toBe(false));
37
+ it('combines separate payments without masking gaps', () => {
38
+ const rows = [explicit, { ...explicit, covered_months: ['2026-02-01'] }];
39
+ expect(range('g1', '2026-01', 3, [explicit])).toBe(false);
40
+ expect(range('g1', '2026-01', 3, rows)).toBe(true);
41
+ expect(range('g1', '2026-01', 4, rows)).toBe(false);
42
+ });
43
+ it('combines legacy cutoff and exact modern months', () => {
44
+ const rows = [explicit, { group_id: 'g1', valid_until: '2026-03-01' }];
45
+ expect(range('g1', '2026-01', 3, rows)).toBe(true);
46
+ expect(paid('g1', '2026-04', rows)).toBe(false);
47
+ });
48
+ it('does not label an unpaid current-month gap active', () => {
49
+ vi.useFakeTimers();
50
+ vi.setSystemTime(new Date(2026, 1, 10));
51
+ expect(
52
+ getGroupPaymentStatus({ id: 'g1' } as WorkspaceUserGroup, explicit, [
53
+ explicit,
54
+ ])
55
+ ).toBe('expired');
56
+ });
57
+ it('uses all invoices for the current-month badge', () => {
58
+ vi.useFakeTimers();
59
+ vi.setSystemTime(new Date(2026, 1, 10));
60
+ const rows = [explicit, { ...explicit, covered_months: ['2026-02-01'] }];
61
+ expect(
62
+ getGroupPaymentStatus({ id: 'g1' } as WorkspaceUserGroup, explicit, rows)
63
+ ).toBe('active');
64
+ });
65
+ it('expires at the next gap rather than the latest paid month', () => {
66
+ vi.useFakeTimers();
67
+ vi.setSystemTime(new Date(2026, 0, 25));
68
+ expect(
69
+ getGroupPaymentStatus({ id: 'g1' } as WorkspaceUserGroup, explicit, [
70
+ explicit,
71
+ ])
72
+ ).toBe('expiringSoon');
73
+ });
74
+ });
@@ -0,0 +1,109 @@
1
+ import {
2
+ getCoverageMonths,
3
+ getMonthStartDate,
4
+ parseLocalCalendarDate,
5
+ } from './civil-month';
6
+ export type SubscriptionCoverageInvoice = {
7
+ created_at?: string | null;
8
+ group_id?: string;
9
+ valid_until?: string | null;
10
+ covered_months?: string[] | null;
11
+ };
12
+
13
+ const getComparableTimestamp = (value: string | null | undefined): number => {
14
+ if (!value) return 0;
15
+ const timestamp = new Date(value).getTime();
16
+ return Number.isNaN(timestamp) ? 0 : timestamp;
17
+ };
18
+
19
+ const getComparableValidUntilTimestamp = (
20
+ invoice: SubscriptionCoverageInvoice
21
+ ): number => {
22
+ const validUntil = parseLocalCalendarDate(invoice.valid_until);
23
+ return Number.isNaN(validUntil.getTime()) ? 0 : validUntil.getTime();
24
+ };
25
+
26
+ export const getSubscriptionCoverageInvoiceForGroup = (
27
+ latestInvoices: SubscriptionCoverageInvoice[],
28
+ groupId: string
29
+ ): SubscriptionCoverageInvoice | undefined =>
30
+ latestInvoices
31
+ .filter((invoice) => invoice.group_id === groupId)
32
+ .filter((invoice) => getComparableValidUntilTimestamp(invoice) > 0)
33
+ .sort((a, b) => {
34
+ const validUntilDiff =
35
+ getComparableValidUntilTimestamp(b) -
36
+ getComparableValidUntilTimestamp(a);
37
+ if (validUntilDiff !== 0) return validUntilDiff;
38
+
39
+ return (
40
+ getComparableTimestamp(b.created_at) -
41
+ getComparableTimestamp(a.created_at)
42
+ );
43
+ })[0];
44
+
45
+ export const isSubscriptionMonthCoveredByInvoice = (
46
+ selectedMonth: string,
47
+ invoice: SubscriptionCoverageInvoice | null | undefined
48
+ ): boolean => {
49
+ if (invoice?.covered_months != null)
50
+ return invoice.covered_months.some(
51
+ (month) => month.slice(0, 7) === selectedMonth
52
+ );
53
+ if (!invoice?.valid_until) return false;
54
+
55
+ const selectedMonthStart = getMonthStartDate(selectedMonth);
56
+ const validUntilMonthStart = getMonthStartDate(invoice.valid_until);
57
+
58
+ if (
59
+ Number.isNaN(selectedMonthStart.getTime()) ||
60
+ Number.isNaN(validUntilMonthStart.getTime())
61
+ ) {
62
+ return false;
63
+ }
64
+
65
+ return selectedMonthStart < validUntilMonthStart;
66
+ };
67
+
68
+ export const isSubscriptionMonthPaidForGroup = (
69
+ groupId: string,
70
+ selectedMonth: string,
71
+ latestInvoices: SubscriptionCoverageInvoice[]
72
+ ): boolean =>
73
+ latestInvoices.some(
74
+ (invoice) =>
75
+ invoice.group_id === groupId &&
76
+ isSubscriptionMonthCoveredByInvoice(selectedMonth, invoice)
77
+ );
78
+
79
+ export const isSubscriptionRangePaidForGroup = (
80
+ groupId: string,
81
+ selectedMonth: string,
82
+ prepaidMonthCount: number,
83
+ latestInvoices: SubscriptionCoverageInvoice[]
84
+ ): boolean => {
85
+ const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
86
+ if (coverageMonths.length === 0) return false;
87
+
88
+ return coverageMonths.every((month) =>
89
+ isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices)
90
+ );
91
+ };
92
+
93
+ export const isSubscriptionRangeFullyPaidForGroups = (
94
+ groupIds: string[],
95
+ selectedMonth: string,
96
+ prepaidMonthCount: number,
97
+ latestInvoices: SubscriptionCoverageInvoice[]
98
+ ): boolean => {
99
+ if (groupIds.length === 0) return false;
100
+
101
+ return groupIds.every((groupId) =>
102
+ isSubscriptionRangePaidForGroup(
103
+ groupId,
104
+ selectedMonth,
105
+ prepaidMonthCount,
106
+ latestInvoices
107
+ )
108
+ );
109
+ };
@@ -61,6 +61,7 @@ import { useSubscriptionInvoiceContent } from './hooks/use-subscription-invoice-
61
61
  import { createSubscriptionInvoiceWithInternalApi } from './internal-api';
62
62
  import { formatInvoiceRecalculationDescription } from './invoice-visibility-format';
63
63
  import { ProductSelection } from './product-selection';
64
+ import { invalidateInvoiceMutationQueries } from './query-invalidation';
64
65
  import type { SubscriptionInvoiceProps } from './subscription-invoice-props';
65
66
  import type { SelectedProductItem } from './types';
66
67
  import {
@@ -800,7 +801,7 @@ export function SubscriptionInvoice({
800
801
 
801
802
  setIsCreating(true);
802
803
  try {
803
- const requestPayload = {
804
+ const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
804
805
  customer_id: selectedUserId,
805
806
  group_ids: selectedGroupIdsForCreate,
806
807
  selected_month: effectiveSelectedMonth,
@@ -815,12 +816,8 @@ export function SubscriptionInvoice({
815
816
  frontend_discount_amount: discountAmount,
816
817
  frontend_total: subscriptionRoundedTotal,
817
818
  prepaid_month_count: prepaidMonthCount,
818
- };
819
-
820
- const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
821
- ...requestPayload,
822
- customer_id: selectedUserId,
823
819
  });
820
+ void invalidateInvoiceMutationQueries(queryClient, wsId);
824
821
 
825
822
  if (result.data?.values_recalculated) {
826
823
  const { calculated_values, frontend_values } = result.data;
@@ -0,0 +1,168 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { SubscriptionPaymentAnalytics } from './subscription-payment-analytics';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ get: vi.fn(),
8
+ chart: vi.fn(),
9
+ userIds: ['u1'],
10
+ walletIds: ['w1'],
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/finance-subscription-analytics', () => ({
13
+ getSubscriptionPaymentAnalytics: mocks.get,
14
+ }));
15
+ vi.mock('next-intl', () => ({
16
+ useLocale: () => 'en',
17
+ useTranslations: () => (key: string, values?: Record<string, unknown>) =>
18
+ values?.count !== undefined ? `${key}:${values.count}` : key,
19
+ }));
20
+ vi.mock('nuqs', () => ({
21
+ parseAsString: {},
22
+ parseAsArrayOf: () => ({ withDefault: () => ({}) }),
23
+ useQueryState: (key: string) => [
24
+ key === 'userIds' ? mocks.userIds : mocks.walletIds,
25
+ ],
26
+ }));
27
+ vi.mock('./charts/subscription-payment-chart', () => ({
28
+ SubscriptionPaymentChart: (props: unknown) => {
29
+ mocks.chart(props);
30
+ return <div data-testid="payment-chart" />;
31
+ },
32
+ }));
33
+ const metrics = {
34
+ paidUsers: 2,
35
+ paidGroups: 3,
36
+ memberships: 4,
37
+ invoiceCount: 5,
38
+ amount: 1234,
39
+ };
40
+ const report = {
41
+ year: 2026,
42
+ granularity: 'monthly',
43
+ unallocatedInvoices: 2,
44
+ currencies: [
45
+ {
46
+ currency: 'VND',
47
+ summary: metrics,
48
+ periods: [{ period: '2026-01', ...metrics }],
49
+ distribution: [{ groupCount: 2, userCount: 2 }],
50
+ },
51
+ ],
52
+ };
53
+ function mount() {
54
+ const client = new QueryClient({
55
+ defaultOptions: { queries: { retry: false } },
56
+ });
57
+ return render(
58
+ <QueryClientProvider client={client}>
59
+ <SubscriptionPaymentAnalytics wsId="ws" currency="VND" />
60
+ </QueryClientProvider>
61
+ );
62
+ }
63
+ describe('tuition analytics interaction', () => {
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+ // biome-ignore lint/suspicious/noDocumentCookie: Reset the persisted visibility preference for each test.
67
+ document.cookie = 'finance-confidential-mode=true;path=/';
68
+ mocks.get.mockResolvedValue(report);
69
+ });
70
+ it('loads selected user and wallet filters and starts with hidden amounts', async () => {
71
+ mount();
72
+ await screen.findByText('unallocated:2');
73
+ expect(mocks.get).toHaveBeenCalledWith(
74
+ 'ws',
75
+ expect.objectContaining({
76
+ granularity: 'monthly',
77
+ userIds: ['u1'],
78
+ walletIds: ['w1'],
79
+ })
80
+ );
81
+ expect(screen.queryByText(/1,234/)).not.toBeInTheDocument();
82
+ expect(screen.getAllByText('•••••').length).toBeGreaterThan(0);
83
+ expect(mocks.chart).toHaveBeenLastCalledWith(
84
+ expect.objectContaining({ hidden: true, metric: 'paidUsers' })
85
+ );
86
+ });
87
+ it('switches coverage granularity and year', async () => {
88
+ mount();
89
+ await screen.findByTestId('payment-chart');
90
+ fireEvent.click(screen.getByRole('button', { name: 'yearly' }));
91
+ await waitFor(() =>
92
+ expect(mocks.get).toHaveBeenLastCalledWith(
93
+ 'ws',
94
+ expect.objectContaining({ granularity: 'yearly' })
95
+ )
96
+ );
97
+ fireEvent.change(screen.getByLabelText('ending_year'), {
98
+ target: { value: '2025' },
99
+ });
100
+ await waitFor(() =>
101
+ expect(mocks.get).toHaveBeenLastCalledWith(
102
+ 'ws',
103
+ expect.objectContaining({ year: 2025, granularity: 'yearly' })
104
+ )
105
+ );
106
+ });
107
+ it('switches the chart metric and reveals value only when requested', async () => {
108
+ mount();
109
+ await screen.findByTestId('payment-chart');
110
+ fireEvent.click(screen.getByRole('button', { name: /amount/ }));
111
+ expect(mocks.chart).toHaveBeenLastCalledWith(
112
+ expect.objectContaining({ metric: 'amount', hidden: true })
113
+ );
114
+ fireEvent.click(screen.getByRole('button', { name: 'show' }));
115
+ expect(mocks.chart).toHaveBeenLastCalledWith(
116
+ expect.objectContaining({ hidden: false })
117
+ );
118
+ expect(screen.getAllByText(/1,234/).length).toBeGreaterThan(0);
119
+ });
120
+ it('switches currencies without combining values', async () => {
121
+ mocks.get.mockResolvedValue({
122
+ ...report,
123
+ currencies: [
124
+ ...report.currencies,
125
+ {
126
+ ...report.currencies[0],
127
+ currency: 'USD',
128
+ summary: { ...metrics, amount: 99 },
129
+ },
130
+ ],
131
+ });
132
+ mount();
133
+ await screen.findByLabelText('currency');
134
+ fireEvent.change(screen.getByLabelText('currency'), {
135
+ target: { value: 'USD' },
136
+ });
137
+ expect(mocks.chart).toHaveBeenLastCalledWith(
138
+ expect.objectContaining({ currency: 'USD' })
139
+ );
140
+ fireEvent.click(screen.getByRole('button', { name: 'show' }));
141
+ expect(screen.getByText('$99.00')).toBeInTheDocument();
142
+ });
143
+ it('shows an error and supports retry', async () => {
144
+ mocks.get.mockRejectedValueOnce(new Error('unavailable'));
145
+ mount();
146
+ await screen.findByRole('alert');
147
+ expect(screen.queryByTestId('payment-chart')).not.toBeInTheDocument();
148
+ fireEvent.click(screen.getByRole('button', { name: 'retry' }));
149
+ await screen.findByTestId('payment-chart');
150
+ });
151
+ it('distinguishes unallocated legacy invoices from an empty exact-coverage report', async () => {
152
+ mocks.get.mockResolvedValue({ ...report, currencies: [] });
153
+ mount();
154
+ await screen.findByText('empty');
155
+ expect(screen.getByText('unallocated:2')).toBeInTheDocument();
156
+ expect(screen.queryByTestId('payment-chart')).not.toBeInTheDocument();
157
+ });
158
+ it('exposes period values in an accessible table and group distribution', async () => {
159
+ mount();
160
+ await screen.findByText('table');
161
+ fireEvent.click(screen.getByText('table'));
162
+ await screen.findByRole('table');
163
+ expect(
164
+ screen.getByRole('rowheader', { name: '2026-01' })
165
+ ).toBeInTheDocument();
166
+ expect(screen.getByLabelText('distribution_value')).toBeInTheDocument();
167
+ });
168
+ });
@@ -0,0 +1,253 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { Eye, EyeOff, RefreshCw } from '@tuturuuu/icons';
5
+ import { getSubscriptionPaymentAnalytics } from '@tuturuuu/internal-api/finance-subscription-analytics';
6
+ import { useLocale, useTranslations } from 'next-intl';
7
+ import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
8
+ import { useId, useState } from 'react';
9
+ import { Button } from '../../button';
10
+ import { Card, CardContent, CardHeader } from '../../card';
11
+ import { Skeleton } from '../../skeleton';
12
+ import {
13
+ FINANCE_HIDDEN_AMOUNT,
14
+ useFinanceConfidentialVisibility,
15
+ } from '../shared/use-finance-confidential-visibility';
16
+ import {
17
+ type PaymentMetric,
18
+ SubscriptionPaymentChart,
19
+ } from './charts/subscription-payment-chart';
20
+ import { SubscriptionPaymentDetails } from './charts/subscription-payment-details';
21
+
22
+ export function SubscriptionPaymentAnalytics({
23
+ wsId,
24
+ currency = 'USD',
25
+ }: {
26
+ wsId: string;
27
+ currency?: string;
28
+ }) {
29
+ const t = useTranslations('invoice-subscriptions');
30
+ const locale = useLocale();
31
+ const [year, setYear] = useState(() => new Date().getFullYear());
32
+ const [granularity, setGranularity] = useState<'monthly' | 'yearly'>(
33
+ 'monthly'
34
+ );
35
+ const [metric, setMetric] = useState<PaymentMetric>('paidUsers');
36
+ const [selectedCurrency, setCurrency] = useState(currency);
37
+ const { isConfidential, toggleConfidential } =
38
+ useFinanceConfidentialVisibility();
39
+ const [userIds] = useQueryState(
40
+ 'userIds',
41
+ parseAsArrayOf(parseAsString).withDefault([])
42
+ );
43
+ const [walletIds] = useQueryState(
44
+ 'walletIds',
45
+ parseAsArrayOf(parseAsString).withDefault([])
46
+ );
47
+ const id = useId();
48
+ const query = useQuery({
49
+ queryKey: [
50
+ 'invoice-subscription-analytics',
51
+ wsId,
52
+ year,
53
+ granularity,
54
+ userIds,
55
+ walletIds,
56
+ ],
57
+ queryFn: () =>
58
+ getSubscriptionPaymentAnalytics(wsId, {
59
+ year,
60
+ granularity,
61
+ userIds,
62
+ walletIds,
63
+ }),
64
+ staleTime: 30_000,
65
+ retry: false,
66
+ });
67
+ const data =
68
+ query.data?.currencies.find(
69
+ (value) => value.currency === selectedCurrency
70
+ ) ?? query.data?.currencies[0];
71
+ const format = (value: number, key: string) =>
72
+ key === 'amount'
73
+ ? isConfidential
74
+ ? FINANCE_HIDDEN_AMOUNT
75
+ : new Intl.NumberFormat(locale, {
76
+ style: 'currency',
77
+ currency: data?.currency ?? currency,
78
+ }).format(value)
79
+ : new Intl.NumberFormat(locale).format(value);
80
+ return (
81
+ <Card>
82
+ <CardHeader className="space-y-4">
83
+ <div className="flex flex-wrap items-start justify-between gap-4">
84
+ <div className="max-w-2xl">
85
+ <h2 className="font-semibold text-lg tracking-tight">
86
+ {t('title')}
87
+ </h2>
88
+ <p className="mt-1 text-muted-foreground text-sm">
89
+ {t('description')}
90
+ </p>
91
+ </div>
92
+ <div className="flex gap-2">
93
+ <Button
94
+ variant="ghost"
95
+ size="icon"
96
+ aria-label={t(isConfidential ? 'show' : 'hide')}
97
+ onClick={toggleConfidential}
98
+ >
99
+ {isConfidential ? (
100
+ <Eye className="size-4" />
101
+ ) : (
102
+ <EyeOff className="size-4" />
103
+ )}
104
+ </Button>
105
+ <Button
106
+ variant="ghost"
107
+ size="icon"
108
+ aria-label={t('refresh')}
109
+ disabled={query.isFetching}
110
+ onClick={() => query.refetch()}
111
+ >
112
+ <RefreshCw className="size-4" />
113
+ </Button>
114
+ </div>
115
+ </div>
116
+ <div className="flex flex-wrap items-end gap-3">
117
+ <fieldset
118
+ className="inline-flex rounded-lg border p-1"
119
+ aria-label={t('granularity')}
120
+ >
121
+ {(['monthly', 'yearly'] as const).map((value) => (
122
+ <Button
123
+ key={value}
124
+ size="sm"
125
+ variant={granularity === value ? 'secondary' : 'ghost'}
126
+ aria-pressed={granularity === value}
127
+ onClick={() => setGranularity(value)}
128
+ >
129
+ {t(value)}
130
+ </Button>
131
+ ))}
132
+ </fieldset>
133
+ <label className="grid gap-1 text-xs" htmlFor={`${id}-year`}>
134
+ {t(granularity === 'monthly' ? 'year' : 'ending_year')}
135
+ <select
136
+ id={`${id}-year`}
137
+ className="h-9 rounded-lg border bg-background px-3 text-sm"
138
+ value={year}
139
+ onChange={(event) => setYear(Number(event.target.value))}
140
+ >
141
+ {Array.from(
142
+ { length: new Date().getFullYear() - 2000 + 3 },
143
+ (_, index) => new Date().getFullYear() + 2 - index
144
+ ).map((value) => (
145
+ <option key={value} value={value}>
146
+ {value}
147
+ </option>
148
+ ))}
149
+ </select>
150
+ </label>
151
+ {(query.data?.currencies.length ?? 0) > 1 && (
152
+ <label className="grid gap-1 text-xs" htmlFor={`${id}-currency`}>
153
+ {t('currency')}
154
+ <select
155
+ id={`${id}-currency`}
156
+ className="h-9 rounded-lg border bg-background px-3 text-sm"
157
+ value={data?.currency}
158
+ onChange={(event) => setCurrency(event.target.value)}
159
+ >
160
+ {query.data?.currencies.map((value) => (
161
+ <option key={value.currency}>{value.currency}</option>
162
+ ))}
163
+ </select>
164
+ </label>
165
+ )}
166
+ <p className="text-muted-foreground text-xs">{t('filters')}</p>
167
+ </div>
168
+ </CardHeader>
169
+ <CardContent className="space-y-5" aria-busy={query.isFetching}>
170
+ {query.isLoading ? (
171
+ <div className="space-y-4">
172
+ <Skeleton className="h-20 w-full" />
173
+ <Skeleton className="h-72 w-full" />
174
+ </div>
175
+ ) : query.isError ? (
176
+ <div
177
+ role="alert"
178
+ className="rounded-lg border border-destructive/30 p-6 text-sm"
179
+ >
180
+ <p>{t('error')}</p>
181
+ <Button
182
+ className="mt-3"
183
+ variant="outline"
184
+ onClick={() => query.refetch()}
185
+ >
186
+ {t('retry')}
187
+ </Button>
188
+ </div>
189
+ ) : (
190
+ <>
191
+ {(query.data?.unallocatedInvoices ?? 0) > 0 && (
192
+ <p
193
+ role="status"
194
+ className="rounded-lg border bg-muted/40 p-3 text-sm"
195
+ >
196
+ {t('unallocated', { count: query.data!.unallocatedInvoices })}
197
+ </p>
198
+ )}
199
+ {!data ? (
200
+ <div className="rounded-xl border border-dashed p-10 text-center">
201
+ <p className="font-medium">{t('empty')}</p>
202
+ <p className="mt-2 text-muted-foreground text-sm">
203
+ {t('empty_help')}
204
+ </p>
205
+ </div>
206
+ ) : (
207
+ <>
208
+ <div className="grid grid-cols-3 gap-3 lg:grid-cols-4">
209
+ {(
210
+ [
211
+ 'paidUsers',
212
+ 'paidGroups',
213
+ 'memberships',
214
+ 'amount',
215
+ ] as const
216
+ ).map((key) => (
217
+ <button
218
+ key={key}
219
+ type="button"
220
+ aria-pressed={metric === key}
221
+ onClick={() => setMetric(key)}
222
+ className={`flex flex-col justify-between rounded-xl border p-3 text-start sm:p-4 ${key === 'amount' ? 'col-span-3 lg:col-span-1' : ''} transition-colors focus-visible:outline-2 focus-visible:outline-ring ${metric === key ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
223
+ >
224
+ <span className="text-muted-foreground text-xs">
225
+ {t(key)}
226
+ </span>
227
+ <span className="mt-2 block break-words font-semibold text-xl tabular-nums tracking-tight">
228
+ {format(data.summary[key], key)}
229
+ </span>
230
+ </button>
231
+ ))}
232
+ </div>
233
+ <SubscriptionPaymentChart
234
+ periods={data.periods}
235
+ metric={metric}
236
+ currency={data.currency}
237
+ hidden={isConfidential}
238
+ />
239
+ <SubscriptionPaymentDetails
240
+ data={data}
241
+ hidden={isConfidential}
242
+ />
243
+ </>
244
+ )}
245
+ <p className="text-muted-foreground text-xs leading-relaxed">
246
+ {t('method')}
247
+ </p>
248
+ </>
249
+ )}
250
+ </CardContent>
251
+ </Card>
252
+ );
253
+ }