@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. 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 (!group?.sessions?.length) return [];
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 (!groupId || !groupIds.includes(groupId)) return false;
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
- | { valid_until?: string | null; created_at?: string | null }
673
- | undefined
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
- const validUntil = latestInvoice?.valid_until
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(