@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
|
@@ -23,6 +23,7 @@ import { InvoiceBlockedState } from './components/invoice-blocked-state';
|
|
|
23
23
|
import { InvoiceCheckoutSummary } from './components/invoice-checkout-summary';
|
|
24
24
|
import { InvoiceContentEditor } from './components/invoice-content-editor';
|
|
25
25
|
import { InvoiceCustomerSelectCard } from './components/invoice-customer-select-card';
|
|
26
|
+
import { InvoiceDataState } from './components/invoice-data-state';
|
|
26
27
|
import { InvoicePaymentSettings } from './components/invoice-payment-settings';
|
|
27
28
|
import {
|
|
28
29
|
InvoiceProductsPermissionWarning,
|
|
@@ -106,6 +107,7 @@ export function StandardInvoice({
|
|
|
106
107
|
selectedUser,
|
|
107
108
|
isLoading: usersLoading,
|
|
108
109
|
error: usersError,
|
|
110
|
+
refetch: refetchUsers,
|
|
109
111
|
hasNextPage: hasMoreCustomers,
|
|
110
112
|
fetchNextPage: fetchMoreCustomers,
|
|
111
113
|
isFetching: isFetchingCustomers,
|
|
@@ -114,6 +116,7 @@ export function StandardInvoice({
|
|
|
114
116
|
const {
|
|
115
117
|
data: products = [],
|
|
116
118
|
error: productsError,
|
|
119
|
+
refetch: refetchProducts,
|
|
117
120
|
isLoading: productsLoading,
|
|
118
121
|
} = useProducts(wsId, { enabled: canReadInvoiceProducts });
|
|
119
122
|
const { data: availablePromotions = [] } = useAvailablePromotions(
|
|
@@ -147,10 +150,12 @@ export function StandardInvoice({
|
|
|
147
150
|
const { data: blockedGroupIds = [] } = useInvoiceBlockedGroups(wsId, {
|
|
148
151
|
enabled: !!selectedUserId,
|
|
149
152
|
});
|
|
150
|
-
const {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
const {
|
|
154
|
+
data: userGroups = [],
|
|
155
|
+
isLoading: userGroupsLoading,
|
|
156
|
+
error: userGroupsError,
|
|
157
|
+
refetch: refetchUserGroups,
|
|
158
|
+
} = useUserGroups(wsId, selectedUserId);
|
|
154
159
|
|
|
155
160
|
const isBlocked = useMemo(() => {
|
|
156
161
|
if (
|
|
@@ -443,16 +448,25 @@ export function StandardInvoice({
|
|
|
443
448
|
}
|
|
444
449
|
};
|
|
445
450
|
|
|
446
|
-
if (
|
|
451
|
+
if (
|
|
452
|
+
isLoadingData ||
|
|
453
|
+
usersError ||
|
|
454
|
+
(canReadInvoiceProducts &&
|
|
455
|
+
productsError &&
|
|
456
|
+
!isPermissionRequestError(productsError)) ||
|
|
457
|
+
userGroupsError
|
|
458
|
+
) {
|
|
447
459
|
return (
|
|
448
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
460
|
+
<InvoiceDataState
|
|
461
|
+
loading={isLoadingData}
|
|
462
|
+
onRetry={() =>
|
|
463
|
+
void Promise.all([
|
|
464
|
+
refetchUsers(),
|
|
465
|
+
...(canReadInvoiceProducts ? [refetchProducts()] : []),
|
|
466
|
+
...(selectedUserId ? [refetchUserGroups()] : []),
|
|
467
|
+
])
|
|
468
|
+
}
|
|
469
|
+
/>
|
|
456
470
|
);
|
|
457
471
|
}
|
|
458
472
|
|
|
@@ -475,9 +489,6 @@ export function StandardInvoice({
|
|
|
475
489
|
isFetchingNextPage={isFetchingMoreCustomers}
|
|
476
490
|
hasNextPage={hasMoreCustomers}
|
|
477
491
|
onLoadMore={() => void fetchMoreCustomers()}
|
|
478
|
-
errorMessage={
|
|
479
|
-
usersError instanceof Error ? usersError.message : undefined
|
|
480
|
-
}
|
|
481
492
|
emptyMessage={t('ws-invoices.no_customers_found')}
|
|
482
493
|
searchValue={customerSearch}
|
|
483
494
|
onSearchChange={setCustomerSearch}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FinancePermissionRequestUser } from '../shared/finance-permission-warning-dialog';
|
|
2
|
+
|
|
3
|
+
export interface SubscriptionInvoiceProps {
|
|
4
|
+
wsId: string;
|
|
5
|
+
prefillQuantity?: number | null;
|
|
6
|
+
suggestedTotal?: number | null;
|
|
7
|
+
createMultipleInvoices: boolean;
|
|
8
|
+
printAfterCreate?: boolean;
|
|
9
|
+
downloadImageAfterCreate?: boolean;
|
|
10
|
+
defaultWalletId?: string;
|
|
11
|
+
defaultCategoryId?: string;
|
|
12
|
+
defaultCurrency?: string;
|
|
13
|
+
workspaceTimezone?: string | null;
|
|
14
|
+
canChangeFinanceWallets?: boolean;
|
|
15
|
+
canSetFinanceWalletsOnCreate?: boolean;
|
|
16
|
+
canReadInvoiceProducts?: boolean;
|
|
17
|
+
canReadInvoiceProductStock?: boolean;
|
|
18
|
+
canReadGroupLinkedProducts?: boolean;
|
|
19
|
+
permissionRequestUser?: FinancePermissionRequestUser | null;
|
|
20
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
-
import { render, waitFor } from '@testing-library/react';
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import type { ComponentProps, PropsWithChildren } from 'react';
|
|
4
4
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { SubscriptionInvoice } from './subscription-invoice';
|
|
@@ -37,6 +37,15 @@ const testState = vi.hoisted(() => {
|
|
|
37
37
|
setMonth: vi.fn(),
|
|
38
38
|
productSelectionInjected: false,
|
|
39
39
|
products: [product],
|
|
40
|
+
productsError: null as Error | null,
|
|
41
|
+
userGroupsError: null as Error | null,
|
|
42
|
+
groupProductsError: null as Error | null,
|
|
43
|
+
contextError: null as Error | null,
|
|
44
|
+
refetchUserGroups: vi.fn(),
|
|
45
|
+
refetchGroupProducts: vi.fn(),
|
|
46
|
+
refetchContext: vi.fn(),
|
|
47
|
+
refetchProducts: vi.fn(),
|
|
48
|
+
refetchUsers: vi.fn(),
|
|
40
49
|
selectedProducts: [{ inventory, product, quantity: 1 }],
|
|
41
50
|
useCategories: vi.fn(),
|
|
42
51
|
useWallets: vi.fn(),
|
|
@@ -170,6 +179,7 @@ vi.mock('./hooks', () => ({
|
|
|
170
179
|
useInvoiceBlockedGroups: () => ({ data: [] }),
|
|
171
180
|
useInvoiceCustomerSearch: () => ({
|
|
172
181
|
customers: [],
|
|
182
|
+
refetch: testState.refetchUsers,
|
|
173
183
|
error: null,
|
|
174
184
|
fetchNextPage: vi.fn(),
|
|
175
185
|
hasNextPage: false,
|
|
@@ -180,21 +190,26 @@ vi.mock('./hooks', () => ({
|
|
|
180
190
|
}),
|
|
181
191
|
useMultiGroupProducts: () => ({
|
|
182
192
|
data: [],
|
|
183
|
-
error:
|
|
193
|
+
error: testState.groupProductsError,
|
|
194
|
+
refetch: testState.refetchGroupProducts,
|
|
184
195
|
isLoading: false,
|
|
185
196
|
}),
|
|
186
197
|
useProducts: () => ({
|
|
187
198
|
data: testState.products,
|
|
188
|
-
error:
|
|
199
|
+
error: testState.productsError,
|
|
200
|
+
refetch: testState.refetchProducts,
|
|
189
201
|
isLoading: false,
|
|
190
202
|
}),
|
|
191
203
|
useSubscriptionInvoiceContext: () => ({
|
|
192
204
|
data: { attendance: [], latestInvoices: [] },
|
|
193
|
-
error:
|
|
205
|
+
error: testState.contextError,
|
|
206
|
+
refetch: testState.refetchContext,
|
|
194
207
|
isLoading: false,
|
|
195
208
|
}),
|
|
196
209
|
useUserGroups: () => ({
|
|
197
210
|
data: testState.userGroups,
|
|
211
|
+
error: testState.userGroupsError,
|
|
212
|
+
refetch: testState.refetchUserGroups,
|
|
198
213
|
isLoading: false,
|
|
199
214
|
}),
|
|
200
215
|
useUserLinkedPromotions: () => ({ data: [] }),
|
|
@@ -272,6 +287,15 @@ describe('SubscriptionInvoice checkout defaults', () => {
|
|
|
272
287
|
testState.useCategories.mockClear();
|
|
273
288
|
testState.useWallets.mockClear();
|
|
274
289
|
testState.productSelectionInjected = false;
|
|
290
|
+
testState.productsError = null;
|
|
291
|
+
testState.userGroupsError = null;
|
|
292
|
+
testState.groupProductsError = null;
|
|
293
|
+
testState.contextError = null;
|
|
294
|
+
testState.refetchUserGroups.mockClear();
|
|
295
|
+
testState.refetchGroupProducts.mockClear();
|
|
296
|
+
testState.refetchContext.mockClear();
|
|
297
|
+
testState.refetchProducts.mockClear();
|
|
298
|
+
testState.refetchUsers.mockClear();
|
|
275
299
|
testState.month = '2026-07';
|
|
276
300
|
testState.setMonth.mockClear();
|
|
277
301
|
testState.userGroups[0]!.workspace_user_groups.ending_date = null;
|
|
@@ -281,6 +305,49 @@ describe('SubscriptionInvoice checkout defaults', () => {
|
|
|
281
305
|
testState.categories = [];
|
|
282
306
|
});
|
|
283
307
|
|
|
308
|
+
it('shows a retry when product loading fails and reopens the form after recovery', async () => {
|
|
309
|
+
testState.productsError = new Error('Timed out');
|
|
310
|
+
const { rerender } = renderSubscriptionInvoice();
|
|
311
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
312
|
+
'ws-invoices.load_failed'
|
|
313
|
+
);
|
|
314
|
+
fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
|
|
315
|
+
expect(testState.refetchProducts).toHaveBeenCalledOnce();
|
|
316
|
+
expect(testState.refetchUsers).toHaveBeenCalledOnce();
|
|
317
|
+
|
|
318
|
+
testState.productsError = null;
|
|
319
|
+
rerender(
|
|
320
|
+
<SubscriptionInvoice wsId="ws-1" createMultipleInvoices={false} />
|
|
321
|
+
);
|
|
322
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
323
|
+
await waitFor(() =>
|
|
324
|
+
expect(testState.InvoicePaymentSettings).toHaveBeenCalled()
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it.each(['userGroupsError', 'groupProductsError', 'contextError'] as const)(
|
|
329
|
+
'recovers from a failed subscription %s read',
|
|
330
|
+
async (key) => {
|
|
331
|
+
testState[key] = new Error('Timed out');
|
|
332
|
+
const { rerender } = renderSubscriptionInvoice();
|
|
333
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
334
|
+
'ws-invoices.load_failed'
|
|
335
|
+
);
|
|
336
|
+
fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
|
|
337
|
+
expect(testState.refetchUserGroups).toHaveBeenCalledOnce();
|
|
338
|
+
expect(testState.refetchGroupProducts).toHaveBeenCalledOnce();
|
|
339
|
+
expect(testState.refetchContext).toHaveBeenCalledOnce();
|
|
340
|
+
testState[key] = null;
|
|
341
|
+
rerender(
|
|
342
|
+
<SubscriptionInvoice wsId="ws-1" createMultipleInvoices={false} />
|
|
343
|
+
);
|
|
344
|
+
await waitFor(() =>
|
|
345
|
+
expect(testState.InvoicePaymentSettings).toHaveBeenCalled()
|
|
346
|
+
);
|
|
347
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
|
|
284
351
|
it('preloads payment options from defaults and keeps default IDs while options load', async () => {
|
|
285
352
|
const { rerender } = renderSubscriptionInvoice();
|
|
286
353
|
|
|
@@ -21,15 +21,13 @@ import {
|
|
|
21
21
|
} from 'react';
|
|
22
22
|
import { useDebounce } from '../../../../hooks/use-debounce';
|
|
23
23
|
import { useFinanceHref } from '../finance-route-context';
|
|
24
|
-
import {
|
|
25
|
-
type FinancePermissionRequestUser,
|
|
26
|
-
FinancePermissionWarningDialog,
|
|
27
|
-
} from '../shared/finance-permission-warning-dialog';
|
|
24
|
+
import { FinancePermissionWarningDialog } from '../shared/finance-permission-warning-dialog';
|
|
28
25
|
import { useFinanceConfidentialVisibility } from '../shared/use-finance-confidential-visibility';
|
|
29
26
|
import { InvoiceBlockedState } from './components/invoice-blocked-state';
|
|
30
27
|
import { InvoiceCheckoutSummary } from './components/invoice-checkout-summary';
|
|
31
28
|
import { InvoiceContentEditor } from './components/invoice-content-editor';
|
|
32
29
|
import { InvoiceCustomerSelectCard } from './components/invoice-customer-select-card';
|
|
30
|
+
import { InvoiceDataState } from './components/invoice-data-state';
|
|
33
31
|
import { InvoicePaymentSettings } from './components/invoice-payment-settings';
|
|
34
32
|
import {
|
|
35
33
|
InvoiceProductsPermissionWarning,
|
|
@@ -63,6 +61,8 @@ import { useSubscriptionInvoiceContent } from './hooks/use-subscription-invoice-
|
|
|
63
61
|
import { createSubscriptionInvoiceWithInternalApi } from './internal-api';
|
|
64
62
|
import { formatInvoiceRecalculationDescription } from './invoice-visibility-format';
|
|
65
63
|
import { ProductSelection } from './product-selection';
|
|
64
|
+
import { invalidateInvoiceMutationQueries } from './query-invalidation';
|
|
65
|
+
import type { SubscriptionInvoiceProps } from './subscription-invoice-props';
|
|
66
66
|
import type { SelectedProductItem } from './types';
|
|
67
67
|
import {
|
|
68
68
|
formatCoverageRangeLabel,
|
|
@@ -82,25 +82,6 @@ import {
|
|
|
82
82
|
resolveSubscriptionInvoiceCategoryId,
|
|
83
83
|
} from './utils';
|
|
84
84
|
|
|
85
|
-
interface Props {
|
|
86
|
-
wsId: string;
|
|
87
|
-
prefillQuantity?: number | null;
|
|
88
|
-
suggestedTotal?: number | null;
|
|
89
|
-
createMultipleInvoices: boolean;
|
|
90
|
-
printAfterCreate?: boolean;
|
|
91
|
-
downloadImageAfterCreate?: boolean;
|
|
92
|
-
defaultWalletId?: string;
|
|
93
|
-
defaultCategoryId?: string;
|
|
94
|
-
defaultCurrency?: string;
|
|
95
|
-
workspaceTimezone?: string | null;
|
|
96
|
-
canChangeFinanceWallets?: boolean;
|
|
97
|
-
canSetFinanceWalletsOnCreate?: boolean;
|
|
98
|
-
canReadInvoiceProducts?: boolean;
|
|
99
|
-
canReadInvoiceProductStock?: boolean;
|
|
100
|
-
canReadGroupLinkedProducts?: boolean;
|
|
101
|
-
permissionRequestUser?: FinancePermissionRequestUser | null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
85
|
export function SubscriptionInvoice({
|
|
105
86
|
wsId,
|
|
106
87
|
prefillQuantity,
|
|
@@ -118,7 +99,7 @@ export function SubscriptionInvoice({
|
|
|
118
99
|
canReadInvoiceProductStock = true,
|
|
119
100
|
canReadGroupLinkedProducts = true,
|
|
120
101
|
permissionRequestUser,
|
|
121
|
-
}:
|
|
102
|
+
}: SubscriptionInvoiceProps) {
|
|
122
103
|
const t = useTranslations();
|
|
123
104
|
const locale = useLocale();
|
|
124
105
|
const defaultCurrency = resolveSupportedCurrency(rawDefaultCurrency);
|
|
@@ -128,7 +109,6 @@ export function SubscriptionInvoice({
|
|
|
128
109
|
const queryClient = useQueryClient();
|
|
129
110
|
const financeHref = useFinanceHref();
|
|
130
111
|
|
|
131
|
-
// URL state using nuqs
|
|
132
112
|
const [selectedUserId, setSelectedUserId] = useQueryState('user_id', {
|
|
133
113
|
defaultValue: '',
|
|
134
114
|
shallow: false,
|
|
@@ -170,12 +150,12 @@ export function SubscriptionInvoice({
|
|
|
170
150
|
[selectedGroupIds]
|
|
171
151
|
);
|
|
172
152
|
|
|
173
|
-
// Data queries
|
|
174
153
|
const {
|
|
175
154
|
customers: users,
|
|
176
155
|
selectedUser,
|
|
177
156
|
isLoading: usersLoading,
|
|
178
157
|
error: usersError,
|
|
158
|
+
refetch: refetchUsers,
|
|
179
159
|
hasNextPage: hasMoreCustomers,
|
|
180
160
|
fetchNextPage: fetchMoreCustomers,
|
|
181
161
|
isFetching: isFetchingCustomers,
|
|
@@ -184,6 +164,7 @@ export function SubscriptionInvoice({
|
|
|
184
164
|
const {
|
|
185
165
|
data: products = [],
|
|
186
166
|
error: productsError,
|
|
167
|
+
refetch: refetchProducts,
|
|
187
168
|
isLoading: productsLoading,
|
|
188
169
|
} = useProducts(wsId, {
|
|
189
170
|
enabled: canReadInvoiceProducts && hasSelectedGroups,
|
|
@@ -222,7 +203,6 @@ export function SubscriptionInvoice({
|
|
|
222
203
|
[selectedGroupIds, blockedGroupIds]
|
|
223
204
|
);
|
|
224
205
|
|
|
225
|
-
// State management
|
|
226
206
|
const [selectedWalletId, setSelectedWalletId] = useState<string>(
|
|
227
207
|
defaultWalletId || ''
|
|
228
208
|
);
|
|
@@ -268,14 +248,16 @@ export function SubscriptionInvoice({
|
|
|
268
248
|
// Track previous user ID to detect user changes (skip initial mount for reset)
|
|
269
249
|
const prevUserIdRef = useRef<string | null>(null);
|
|
270
250
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
251
|
+
const {
|
|
252
|
+
data: userGroups = [],
|
|
253
|
+
isLoading: userGroupsLoading,
|
|
254
|
+
error: userGroupsError,
|
|
255
|
+
refetch: refetchUserGroups,
|
|
256
|
+
} = useUserGroups(wsId, selectedUserId);
|
|
276
257
|
const {
|
|
277
258
|
data: groupProducts = [],
|
|
278
259
|
error: groupProductsError,
|
|
260
|
+
refetch: refetchGroupProducts,
|
|
279
261
|
isLoading: groupProductsLoading,
|
|
280
262
|
} = useMultiGroupProducts(wsId, selectedGroupIds, {
|
|
281
263
|
enabled: canReadGroupLinkedProducts,
|
|
@@ -341,6 +323,7 @@ export function SubscriptionInvoice({
|
|
|
341
323
|
data: subscriptionInvoiceContext,
|
|
342
324
|
isLoading: subscriptionInvoiceContextLoading,
|
|
343
325
|
error: subscriptionInvoiceContextError,
|
|
326
|
+
refetch: refetchSubscriptionContext,
|
|
344
327
|
} = useSubscriptionInvoiceContext(
|
|
345
328
|
wsId,
|
|
346
329
|
selectedUserId,
|
|
@@ -547,7 +530,6 @@ export function SubscriptionInvoice({
|
|
|
547
530
|
return map;
|
|
548
531
|
}, [referralDiscountRows]);
|
|
549
532
|
|
|
550
|
-
// Use hooks for logic
|
|
551
533
|
useSubscriptionAutoSelection({
|
|
552
534
|
enabled: true,
|
|
553
535
|
selectedGroupIds,
|
|
@@ -819,7 +801,7 @@ export function SubscriptionInvoice({
|
|
|
819
801
|
|
|
820
802
|
setIsCreating(true);
|
|
821
803
|
try {
|
|
822
|
-
const
|
|
804
|
+
const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
|
|
823
805
|
customer_id: selectedUserId,
|
|
824
806
|
group_ids: selectedGroupIdsForCreate,
|
|
825
807
|
selected_month: effectiveSelectedMonth,
|
|
@@ -834,12 +816,8 @@ export function SubscriptionInvoice({
|
|
|
834
816
|
frontend_discount_amount: discountAmount,
|
|
835
817
|
frontend_total: subscriptionRoundedTotal,
|
|
836
818
|
prepaid_month_count: prepaidMonthCount,
|
|
837
|
-
};
|
|
838
|
-
|
|
839
|
-
const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
|
|
840
|
-
...requestPayload,
|
|
841
|
-
customer_id: selectedUserId,
|
|
842
819
|
});
|
|
820
|
+
void invalidateInvoiceMutationQueries(queryClient, wsId);
|
|
843
821
|
|
|
844
822
|
if (result.data?.values_recalculated) {
|
|
845
823
|
const { calculated_values, frontend_values } = result.data;
|
|
@@ -897,16 +875,37 @@ export function SubscriptionInvoice({
|
|
|
897
875
|
}
|
|
898
876
|
};
|
|
899
877
|
|
|
900
|
-
if (
|
|
878
|
+
if (
|
|
879
|
+
isLoadingData ||
|
|
880
|
+
usersError ||
|
|
881
|
+
(selectedUserId && userGroupsError) ||
|
|
882
|
+
(hasSelectedGroups &&
|
|
883
|
+
canReadGroupLinkedProducts &&
|
|
884
|
+
groupProductsError &&
|
|
885
|
+
!isPermissionRequestError(groupProductsError)) ||
|
|
886
|
+
(selectedUserId &&
|
|
887
|
+
hasSelectedGroups &&
|
|
888
|
+
subscriptionInvoiceContextError &&
|
|
889
|
+
!isPermissionRequestError(subscriptionInvoiceContextError)) ||
|
|
890
|
+
(canReadInvoiceProducts &&
|
|
891
|
+
hasSelectedGroups &&
|
|
892
|
+
productsError &&
|
|
893
|
+
!isPermissionRequestError(productsError))
|
|
894
|
+
) {
|
|
901
895
|
return (
|
|
902
|
-
<
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
896
|
+
<InvoiceDataState
|
|
897
|
+
loading={isLoadingData || isLoadingSubscriptionData}
|
|
898
|
+
onRetry={() => {
|
|
899
|
+
void refetchUsers();
|
|
900
|
+
if (selectedUserId) void refetchUserGroups();
|
|
901
|
+
if (selectedUserId && hasSelectedGroups)
|
|
902
|
+
void refetchSubscriptionContext();
|
|
903
|
+
if (hasSelectedGroups && canReadGroupLinkedProducts)
|
|
904
|
+
void refetchGroupProducts();
|
|
905
|
+
if (canReadInvoiceProducts && hasSelectedGroups)
|
|
906
|
+
void refetchProducts();
|
|
907
|
+
}}
|
|
908
|
+
/>
|
|
910
909
|
);
|
|
911
910
|
}
|
|
912
911
|
|
|
@@ -929,9 +928,6 @@ export function SubscriptionInvoice({
|
|
|
929
928
|
isFetchingNextPage={isFetchingMoreCustomers}
|
|
930
929
|
hasNextPage={hasMoreCustomers}
|
|
931
930
|
onLoadMore={() => void fetchMoreCustomers()}
|
|
932
|
-
errorMessage={
|
|
933
|
-
usersError instanceof Error ? usersError.message : undefined
|
|
934
|
-
}
|
|
935
931
|
emptyMessage={t('ws-invoices.no_customers_found')}
|
|
936
932
|
searchValue={customerSearch}
|
|
937
933
|
onSearchChange={setCustomerSearch}
|