@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.
- package/CHANGELOG.md +13 -0
- package/package.json +5 -5
- package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +2 -0
- package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +3 -6
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- package/src/components/ui/legacy/calendar/working-location.ts +35 -0
|
@@ -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
|
|
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
|
+
}
|