@tuturuuu/ui 0.32.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 +62 -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-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/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -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/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/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/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-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/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/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-analytics.tsx +24 -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.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +3 -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-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- 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 +47 -51
- 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/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/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
- 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,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
|
+
}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSubscriptionCoverageInvoiceForGroup,
|
|
3
|
+
isSubscriptionMonthPaidForGroup,
|
|
4
|
+
type SubscriptionCoverageInvoice,
|
|
5
|
+
} from './subscription-coverage';
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
getSubscriptionCoverageInvoiceForGroup,
|
|
9
|
+
isSubscriptionMonthCoveredByInvoice,
|
|
10
|
+
isSubscriptionMonthPaidForGroup,
|
|
11
|
+
isSubscriptionRangeFullyPaidForGroups,
|
|
12
|
+
isSubscriptionRangePaidForGroup,
|
|
13
|
+
type SubscriptionCoverageInvoice,
|
|
14
|
+
} from './subscription-coverage';
|
|
15
|
+
|
|
1
16
|
import type { Database } from '@tuturuuu/types';
|
|
2
17
|
import {
|
|
3
18
|
formatMonthLabel,
|
|
@@ -48,12 +63,6 @@ export type UserGroup = {
|
|
|
48
63
|
workspace_user_groups: WorkspaceUserGroup | null;
|
|
49
64
|
};
|
|
50
65
|
|
|
51
|
-
export type SubscriptionCoverageInvoice = {
|
|
52
|
-
created_at?: string | null;
|
|
53
|
-
group_id?: string;
|
|
54
|
-
valid_until?: string | null;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
66
|
export type BillableSession = {
|
|
58
67
|
date: string;
|
|
59
68
|
groupId: string;
|
|
@@ -114,99 +123,6 @@ export const resolveSubscriptionInvoiceCategoryId = ({
|
|
|
114
123
|
return defaultCategoryId ?? '';
|
|
115
124
|
};
|
|
116
125
|
|
|
117
|
-
const getComparableTimestamp = (value: string | null | undefined): number => {
|
|
118
|
-
if (!value) return 0;
|
|
119
|
-
const timestamp = new Date(value).getTime();
|
|
120
|
-
return Number.isNaN(timestamp) ? 0 : timestamp;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const getComparableValidUntilTimestamp = (
|
|
124
|
-
invoice: SubscriptionCoverageInvoice
|
|
125
|
-
): number => {
|
|
126
|
-
const validUntil = parseLocalCalendarDate(invoice.valid_until);
|
|
127
|
-
return Number.isNaN(validUntil.getTime()) ? 0 : validUntil.getTime();
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
export const getSubscriptionCoverageInvoiceForGroup = (
|
|
131
|
-
latestInvoices: SubscriptionCoverageInvoice[],
|
|
132
|
-
groupId: string
|
|
133
|
-
): SubscriptionCoverageInvoice | undefined =>
|
|
134
|
-
latestInvoices
|
|
135
|
-
.filter((invoice) => invoice.group_id === groupId)
|
|
136
|
-
.filter((invoice) => getComparableValidUntilTimestamp(invoice) > 0)
|
|
137
|
-
.sort((a, b) => {
|
|
138
|
-
const validUntilDiff =
|
|
139
|
-
getComparableValidUntilTimestamp(b) -
|
|
140
|
-
getComparableValidUntilTimestamp(a);
|
|
141
|
-
if (validUntilDiff !== 0) return validUntilDiff;
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
getComparableTimestamp(b.created_at) -
|
|
145
|
-
getComparableTimestamp(a.created_at)
|
|
146
|
-
);
|
|
147
|
-
})[0];
|
|
148
|
-
|
|
149
|
-
export const isSubscriptionMonthCoveredByInvoice = (
|
|
150
|
-
selectedMonth: string,
|
|
151
|
-
invoice: SubscriptionCoverageInvoice | null | undefined
|
|
152
|
-
): boolean => {
|
|
153
|
-
if (!invoice?.valid_until) return false;
|
|
154
|
-
|
|
155
|
-
const selectedMonthStart = getMonthStartDate(selectedMonth);
|
|
156
|
-
const validUntilMonthStart = getMonthStartDate(invoice.valid_until);
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
Number.isNaN(selectedMonthStart.getTime()) ||
|
|
160
|
-
Number.isNaN(validUntilMonthStart.getTime())
|
|
161
|
-
) {
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return selectedMonthStart < validUntilMonthStart;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
export const isSubscriptionMonthPaidForGroup = (
|
|
169
|
-
groupId: string,
|
|
170
|
-
selectedMonth: string,
|
|
171
|
-
latestInvoices: SubscriptionCoverageInvoice[]
|
|
172
|
-
): boolean =>
|
|
173
|
-
isSubscriptionMonthCoveredByInvoice(
|
|
174
|
-
selectedMonth,
|
|
175
|
-
getSubscriptionCoverageInvoiceForGroup(latestInvoices, groupId)
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
export const isSubscriptionRangePaidForGroup = (
|
|
179
|
-
groupId: string,
|
|
180
|
-
selectedMonth: string,
|
|
181
|
-
prepaidMonthCount: number,
|
|
182
|
-
latestInvoices: SubscriptionCoverageInvoice[]
|
|
183
|
-
): boolean => {
|
|
184
|
-
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
185
|
-
if (coverageMonths.length === 0) return false;
|
|
186
|
-
|
|
187
|
-
return coverageMonths.every((month) =>
|
|
188
|
-
isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices)
|
|
189
|
-
);
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
export const isSubscriptionRangeFullyPaidForGroups = (
|
|
193
|
-
groupIds: string[],
|
|
194
|
-
selectedMonth: string,
|
|
195
|
-
prepaidMonthCount: number,
|
|
196
|
-
latestInvoices: SubscriptionCoverageInvoice[]
|
|
197
|
-
): boolean => {
|
|
198
|
-
if (groupIds.length === 0) return false;
|
|
199
|
-
|
|
200
|
-
return groupIds.every((groupId) =>
|
|
201
|
-
isSubscriptionRangePaidForGroup(
|
|
202
|
-
groupId,
|
|
203
|
-
selectedMonth,
|
|
204
|
-
prepaidMonthCount,
|
|
205
|
-
latestInvoices
|
|
206
|
-
)
|
|
207
|
-
);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
126
|
export const getAttendanceStats = (
|
|
211
127
|
attendance: AttendanceRecord[]
|
|
212
128
|
): AttendanceStats => {
|
|
@@ -251,7 +167,7 @@ const getGroupValidUntilDate = (
|
|
|
251
167
|
groupId: string
|
|
252
168
|
): Date | null => {
|
|
253
169
|
const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
|
|
254
|
-
latestInvoices,
|
|
170
|
+
latestInvoices.filter((invoice) => invoice.covered_months == null),
|
|
255
171
|
groupId
|
|
256
172
|
);
|
|
257
173
|
if (!latestInvoice?.valid_until) return null;
|
|
@@ -285,7 +201,11 @@ export const getBillableSessionsForGroups = (
|
|
|
285
201
|
(candidate) => candidate.workspace_user_groups?.id === groupId
|
|
286
202
|
)?.workspace_user_groups;
|
|
287
203
|
|
|
288
|
-
if (
|
|
204
|
+
if (
|
|
205
|
+
!group?.sessions?.length ||
|
|
206
|
+
isSubscriptionMonthPaidForGroup(groupId, selectedMonth, latestInvoices)
|
|
207
|
+
)
|
|
208
|
+
return [];
|
|
289
209
|
|
|
290
210
|
const validUntil = getGroupValidUntilDate(latestInvoices, groupId);
|
|
291
211
|
|
|
@@ -330,7 +250,12 @@ export const getBillableAttendanceRecords = (
|
|
|
330
250
|
|
|
331
251
|
return attendance.filter((record) => {
|
|
332
252
|
const groupId = record.group_id;
|
|
333
|
-
if (
|
|
253
|
+
if (
|
|
254
|
+
!groupId ||
|
|
255
|
+
!groupIds.includes(groupId) ||
|
|
256
|
+
isSubscriptionMonthPaidForGroup(groupId, selectedMonth, latestInvoices)
|
|
257
|
+
)
|
|
258
|
+
return false;
|
|
334
259
|
|
|
335
260
|
const attendanceDate = parseLocalCalendarDate(record.date);
|
|
336
261
|
if (!isDateInMonth(attendanceDate, selectedMonth)) return false;
|
|
@@ -668,9 +593,10 @@ const EXPIRING_SOON_DAYS = 14;
|
|
|
668
593
|
export type GroupPaymentStatus = 'active' | 'expiringSoon' | 'expired';
|
|
669
594
|
export function getGroupPaymentStatus(
|
|
670
595
|
group: WorkspaceUserGroup | null,
|
|
671
|
-
latestInvoice:
|
|
672
|
-
|
|
673
|
-
|
|
596
|
+
latestInvoice: SubscriptionCoverageInvoice | undefined,
|
|
597
|
+
coverageInvoices: SubscriptionCoverageInvoice[] = latestInvoice
|
|
598
|
+
? [latestInvoice]
|
|
599
|
+
: []
|
|
674
600
|
): GroupPaymentStatus {
|
|
675
601
|
const today = new Date();
|
|
676
602
|
today.setHours(0, 0, 0, 0);
|
|
@@ -685,9 +611,25 @@ export function getGroupPaymentStatus(
|
|
|
685
611
|
if (today > endDate) return 'expired';
|
|
686
612
|
}
|
|
687
613
|
|
|
688
|
-
|
|
614
|
+
let validUntil = latestInvoice?.valid_until
|
|
689
615
|
? parseLocalCalendarDate(latestInvoice.valid_until)
|
|
690
616
|
: null;
|
|
617
|
+
if (coverageInvoices.some((invoice) => invoice.covered_months != null)) {
|
|
618
|
+
const cursor = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
619
|
+
const month = () =>
|
|
620
|
+
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
|
|
621
|
+
if (!isSubscriptionMonthPaidForGroup(group.id, month(), coverageInvoices))
|
|
622
|
+
return 'expired';
|
|
623
|
+
// Walk contiguous paid months so a later payment cannot conceal a gap.
|
|
624
|
+
for (
|
|
625
|
+
let i = 0;
|
|
626
|
+
i < 1200 &&
|
|
627
|
+
isSubscriptionMonthPaidForGroup(group.id, month(), coverageInvoices);
|
|
628
|
+
i++
|
|
629
|
+
)
|
|
630
|
+
cursor.setMonth(cursor.getMonth() + 1);
|
|
631
|
+
validUntil = cursor;
|
|
632
|
+
}
|
|
691
633
|
if (validUntil) {
|
|
692
634
|
validUntil.setHours(0, 0, 0, 0);
|
|
693
635
|
const daysUntilExpiry = Math.ceil(
|