@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,121 @@
1
+ 'use client';
2
+ import type { InvoiceHistoryEntry } from '@tuturuuu/internal-api/finance';
3
+ import { Input } from '@tuturuuu/ui/input';
4
+ import { Label } from '@tuturuuu/ui/label';
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@tuturuuu/ui/select';
12
+ import { useTranslations } from 'next-intl';
13
+
14
+ export interface InvoiceHistoryFilters {
15
+ entity: InvoiceHistoryEntry['entity_type'] | 'all';
16
+ action: InvoiceHistoryEntry['operation'] | 'all';
17
+ from: string;
18
+ to: string;
19
+ sort: 'asc' | 'desc';
20
+ }
21
+ export const DEFAULT_HISTORY_FILTERS: InvoiceHistoryFilters = {
22
+ entity: 'all',
23
+ action: 'all',
24
+ from: '',
25
+ to: '',
26
+ sort: 'desc',
27
+ };
28
+
29
+ export function InvoiceHistoryFilterBar({
30
+ value,
31
+ onChange,
32
+ }: {
33
+ value: InvoiceHistoryFilters;
34
+ onChange: (filters: InvoiceHistoryFilters) => void;
35
+ }) {
36
+ const t = useTranslations('ws-invoices');
37
+ return (
38
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
39
+ <div className="space-y-1">
40
+ <Label htmlFor="invoice-history-entity">
41
+ {t('activity_record_type')}
42
+ </Label>
43
+ <Select
44
+ value={value.entity}
45
+ onValueChange={(entity: InvoiceHistoryFilters['entity']) =>
46
+ onChange({ ...value, entity })
47
+ }
48
+ >
49
+ <SelectTrigger id="invoice-history-entity">
50
+ <SelectValue />
51
+ </SelectTrigger>
52
+ <SelectContent>
53
+ <SelectItem value="all">{t('activity_all_types')}</SelectItem>
54
+ {(
55
+ ['invoice', 'product', 'promotion', 'group', 'payment'] as const
56
+ ).map((entity) => (
57
+ <SelectItem key={entity} value={entity}>
58
+ {t(`history_entities.${entity}`)}
59
+ </SelectItem>
60
+ ))}
61
+ </SelectContent>
62
+ </Select>
63
+ </div>
64
+ <div className="space-y-1">
65
+ <Label htmlFor="invoice-history-action">{t('activity_action')}</Label>
66
+ <Select
67
+ value={value.action}
68
+ onValueChange={(action: InvoiceHistoryFilters['action']) =>
69
+ onChange({ ...value, action })
70
+ }
71
+ >
72
+ <SelectTrigger id="invoice-history-action">
73
+ <SelectValue />
74
+ </SelectTrigger>
75
+ <SelectContent>
76
+ <SelectItem value="all">{t('activity_all_actions')}</SelectItem>
77
+ <SelectItem value="INSERT">{t('activity_created')}</SelectItem>
78
+ <SelectItem value="UPDATE">{t('activity_updated')}</SelectItem>
79
+ <SelectItem value="DELETE">{t('activity_deleted')}</SelectItem>
80
+ <SelectItem value="RESTORE">{t('activity_restored')}</SelectItem>
81
+ </SelectContent>
82
+ </Select>
83
+ </div>
84
+ <div className="space-y-1">
85
+ <Label htmlFor="invoice-history-from">{t('activity_from')}</Label>
86
+ <Input
87
+ id="invoice-history-from"
88
+ type="date"
89
+ value={value.from}
90
+ max={value.to || undefined}
91
+ onChange={(event) => onChange({ ...value, from: event.target.value })}
92
+ />
93
+ </div>
94
+ <div className="space-y-1">
95
+ <Label htmlFor="invoice-history-to">{t('activity_to')}</Label>
96
+ <Input
97
+ id="invoice-history-to"
98
+ type="date"
99
+ value={value.to}
100
+ min={value.from || undefined}
101
+ onChange={(event) => onChange({ ...value, to: event.target.value })}
102
+ />
103
+ </div>
104
+ <div className="col-span-2 space-y-1 lg:col-span-1">
105
+ <Label htmlFor="invoice-history-sort">{t('activity_sort')}</Label>
106
+ <Select
107
+ value={value.sort}
108
+ onValueChange={(sort: 'asc' | 'desc') => onChange({ ...value, sort })}
109
+ >
110
+ <SelectTrigger id="invoice-history-sort">
111
+ <SelectValue />
112
+ </SelectTrigger>
113
+ <SelectContent>
114
+ <SelectItem value="desc">{t('activity_newest')}</SelectItem>
115
+ <SelectItem value="asc">{t('activity_oldest')}</SelectItem>
116
+ </SelectContent>
117
+ </Select>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+ import { Button } from '@tuturuuu/ui/button';
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from '@tuturuuu/ui/select';
10
+ import { useTranslations } from 'next-intl';
11
+
12
+ export function InvoiceHistoryPagination({
13
+ page,
14
+ pageSize,
15
+ hasMore,
16
+ busy,
17
+ failed,
18
+ onPageChange,
19
+ onPageSizeChange,
20
+ }: {
21
+ page: number;
22
+ pageSize: number;
23
+ hasMore: boolean;
24
+ busy: boolean;
25
+ failed: boolean;
26
+ onPageChange: (page: number) => void;
27
+ onPageSizeChange: (size: number) => void;
28
+ }) {
29
+ const t = useTranslations('ws-invoices');
30
+ return (
31
+ <nav
32
+ aria-label={t('activity_pagination')}
33
+ className="flex flex-wrap items-center justify-between gap-3 border-t pt-4"
34
+ >
35
+ <div className="flex items-center gap-2 text-muted-foreground text-sm">
36
+ <span>{t('activity_rows_per_page')}</span>
37
+ <Select
38
+ value={String(pageSize)}
39
+ onValueChange={(value) => {
40
+ onPageSizeChange(Number(value));
41
+ }}
42
+ >
43
+ <SelectTrigger
44
+ className="w-20"
45
+ aria-label={t('activity_rows_per_page')}
46
+ >
47
+ <SelectValue />
48
+ </SelectTrigger>
49
+ <SelectContent>
50
+ {[10, 25, 50].map((size) => (
51
+ <SelectItem key={size} value={String(size)}>
52
+ {size}
53
+ </SelectItem>
54
+ ))}
55
+ </SelectContent>
56
+ </Select>
57
+ </div>
58
+ <div className="flex items-center gap-2">
59
+ <Button
60
+ variant="outline"
61
+ size="sm"
62
+ disabled={page === 0 || busy}
63
+ onClick={() => onPageChange(page - 1)}
64
+ >
65
+ {t('activity_previous')}
66
+ </Button>
67
+ <span className="text-muted-foreground text-sm tabular-nums">
68
+ {t('activity_page', { page: page + 1 })}
69
+ </span>
70
+ <Button
71
+ variant="outline"
72
+ size="sm"
73
+ disabled={busy || failed || !hasMore}
74
+ onClick={() => onPageChange(page + 1)}
75
+ >
76
+ {t('activity_next')}
77
+ </Button>
78
+ </div>
79
+ </nav>
80
+ );
81
+ }
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+ import { RotateCcw } from '@tuturuuu/icons';
3
+ import type { InvoiceHistoryEntry } from '@tuturuuu/internal-api/finance';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import { useFormatter, useTranslations } from 'next-intl';
7
+ import {
8
+ FINANCE_HIDDEN_AMOUNT,
9
+ useFinanceConfidentialVisibility,
10
+ } from '../shared/use-finance-confidential-visibility';
11
+
12
+ export function InvoiceHistoryRow({
13
+ entry,
14
+ timeZone,
15
+ canRestore,
16
+ restoring,
17
+ onRestore,
18
+ }: {
19
+ entry: InvoiceHistoryEntry;
20
+ timeZone?: string;
21
+ canRestore: boolean;
22
+ restoring: boolean;
23
+ onRestore: () => void;
24
+ }) {
25
+ const t = useTranslations('ws-invoices');
26
+ const format = useFormatter();
27
+ const { isConfidential } = useFinanceConfidentialVisibility();
28
+ const displayValue = (
29
+ field: string,
30
+ value: string | number | boolean | null
31
+ ) => {
32
+ if (value == null) return '—';
33
+ if (
34
+ isConfidential &&
35
+ ['amount', 'price', 'paid_amount', 'total_diff', 'value'].includes(field)
36
+ )
37
+ return FINANCE_HIDDEN_AMOUNT;
38
+ return typeof value === 'number' ? format.number(value) : String(value);
39
+ };
40
+ return (
41
+ <li
42
+ className={`flex flex-wrap items-start justify-between gap-3 p-4 ${entry.entity_type === 'invoice' && entry.operation === 'DELETE' && entry.is_deleted ? 'border-l-2 border-l-destructive bg-destructive/5' : ''}`}
43
+ >
44
+ <div className="min-w-0 flex-1 space-y-2">
45
+ <div className="flex flex-wrap items-center gap-2">
46
+ <Badge
47
+ variant={entry.operation === 'DELETE' ? 'destructive' : 'secondary'}
48
+ >
49
+ {t(
50
+ entry.operation === 'DELETE'
51
+ ? 'activity_deleted'
52
+ : entry.operation === 'UPDATE'
53
+ ? 'activity_updated'
54
+ : entry.operation === 'RESTORE'
55
+ ? 'activity_restored'
56
+ : 'activity_created'
57
+ )}
58
+ </Badge>
59
+ <Badge variant="outline">
60
+ {t(`history_entities.${entry.entity_type}`)}
61
+ </Badge>
62
+ <span className="font-medium text-sm">
63
+ {entry.customer_name || t('activity_unknown_customer')}
64
+ </span>
65
+ </div>
66
+ {entry.amount != null && (
67
+ <p className="font-semibold text-sm tabular-nums">
68
+ {isConfidential
69
+ ? FINANCE_HIDDEN_AMOUNT
70
+ : format.number(entry.amount)}{' '}
71
+ {entry.currency?.toUpperCase()}
72
+ </p>
73
+ )}
74
+ {entry.entity_type === 'invoice' &&
75
+ entry.operation === 'DELETE' &&
76
+ entry.is_deleted && (
77
+ <Badge variant="outline">
78
+ {t(
79
+ entry.can_restore
80
+ ? 'recovery_ready'
81
+ : 'recovery_review_required'
82
+ )}
83
+ </Badge>
84
+ )}
85
+ <p className="break-all font-mono text-muted-foreground text-xs">
86
+ {entry.invoice_id}
87
+ </p>
88
+ <p className="text-muted-foreground text-xs">
89
+ {entry.actor_name ||
90
+ (entry.actor_id
91
+ ? t('activity_recorded_actor', { id: entry.actor_id })
92
+ : t('activity_unknown_actor'))}{' '}
93
+ ·{' '}
94
+ <time dateTime={entry.occurred_at}>
95
+ {format.dateTime(new Date(entry.occurred_at), {
96
+ dateStyle: 'medium',
97
+ timeStyle: 'medium',
98
+ timeZone,
99
+ })}
100
+ </time>
101
+ </p>
102
+ {entry.operation === 'UPDATE' && (
103
+ <p className="text-muted-foreground text-xs">
104
+ {t('activity_changed_fields')}:{' '}
105
+ {entry.changed_fields
106
+ .map((field) =>
107
+ t.has(`history_fields.${field}`)
108
+ ? t(`history_fields.${field}`)
109
+ : field
110
+ )
111
+ .join(', ')}
112
+ </p>
113
+ )}
114
+ {Object.keys(entry.changes ?? {}).length > 0 && (
115
+ <details className="text-sm">
116
+ <summary className="cursor-pointer text-muted-foreground">
117
+ {t('activity_details')}
118
+ </summary>
119
+ <dl className="mt-2 space-y-3 rounded-md bg-muted/40 p-3">
120
+ {Object.entries(entry.changes).map(([field, values]) => (
121
+ <div key={field} className="space-y-1">
122
+ <dt className="font-medium">
123
+ {t.has(`history_fields.${field}`)
124
+ ? t(`history_fields.${field}`)
125
+ : field}
126
+ </dt>
127
+ <dd className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
128
+ {t('activity_before')}: {displayValue(field, values.before)}
129
+ </dd>
130
+ <dd className="whitespace-pre-wrap break-words text-xs">
131
+ {t('activity_after')}: {displayValue(field, values.after)}
132
+ </dd>
133
+ </div>
134
+ ))}
135
+ </dl>
136
+ </details>
137
+ )}
138
+ {entry.entity_type === 'invoice' &&
139
+ entry.operation === 'DELETE' &&
140
+ !entry.can_restore && (
141
+ <p className="max-w-xl text-muted-foreground text-xs">
142
+ {t('recovery_unavailable')}
143
+ </p>
144
+ )}
145
+ </div>
146
+ {entry.can_restore && canRestore && (
147
+ <Button
148
+ variant="outline"
149
+ size="sm"
150
+ disabled={restoring}
151
+ onClick={onRestore}
152
+ >
153
+ <RotateCcw className="size-4" />
154
+ {t('restore_invoice')}
155
+ </Button>
156
+ )}
157
+ </li>
158
+ );
159
+ }
@@ -0,0 +1,170 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ query: vi.fn(),
6
+ mutate: vi.fn(),
7
+ refetch: vi.fn(),
8
+ confidential: false,
9
+ toggleConfidential: vi.fn(),
10
+ }));
11
+ vi.mock('../shared/use-finance-confidential-visibility', () => ({
12
+ FINANCE_HIDDEN_AMOUNT: '•••••',
13
+ useFinanceConfidentialVisibility: () => ({
14
+ isConfidential: mocks.confidential,
15
+ toggleConfidential: mocks.toggleConfidential,
16
+ }),
17
+ }));
18
+ vi.mock('@tanstack/react-query', () => ({
19
+ keepPreviousData: (data: unknown) => data,
20
+ useQuery: mocks.query,
21
+ useMutation: () => ({ mutate: mocks.mutate, isPending: false }),
22
+ useQueryClient: () => ({}),
23
+ }));
24
+ vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
25
+ vi.mock('next-intl', () => ({
26
+ useTranslations: () =>
27
+ Object.assign((key: string) => key, { has: () => true }),
28
+ useFormatter: () => ({
29
+ dateTime: () => 'Sep 16, 2026',
30
+ number: (value: number) => String(value),
31
+ }),
32
+ }));
33
+ vi.mock('@tuturuuu/internal-api/finance', () => ({
34
+ getInvoiceHistory: vi.fn(),
35
+ restoreInvoice: vi.fn(),
36
+ }));
37
+
38
+ import { InvoiceHistory } from './invoice-history';
39
+
40
+ const entry = {
41
+ id: '1',
42
+ occurred_at: '2026-09-16T11:30:00Z',
43
+ operation: 'DELETE',
44
+ entity_type: 'invoice',
45
+ invoice_id: 'invoice-id',
46
+ actor_name: 'Actor',
47
+ customer_name: 'Customer',
48
+ changed_fields: [],
49
+ can_restore: true,
50
+ is_deleted: true,
51
+ amount: 100,
52
+ currency: 'VND',
53
+ };
54
+ describe('InvoiceHistory', () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ mocks.confidential = false;
58
+ mocks.query.mockReturnValue({
59
+ data: { data: [entry], hasMore: false },
60
+ isPending: false,
61
+ isError: false,
62
+ refetch: mocks.refetch,
63
+ });
64
+ });
65
+ it('opens with deleted invoices highlighted for administrator review', () => {
66
+ render(<InvoiceHistory wsId="workspace" canRestore />);
67
+ expect(mocks.query.mock.calls[0]?.[0].queryKey[2]).toBe(true);
68
+ expect(screen.getByText('recovery_review_title')).toBeInTheDocument();
69
+ expect(screen.getByText('100 VND')).toBeInTheDocument();
70
+ expect(screen.getByText('recovery_ready')).toBeInTheDocument();
71
+ expect(mocks.mutate).not.toHaveBeenCalled();
72
+ fireEvent.click(screen.getByRole('button', { name: 'activity_show_all' }));
73
+ expect(mocks.query.mock.lastCall?.[0].queryKey[2]).toBe(false);
74
+ });
75
+ it('respects finance amount visibility in the audit trail', () => {
76
+ mocks.confidential = true;
77
+ render(<InvoiceHistory wsId="workspace" canRestore />);
78
+ expect(screen.queryByText('100 VND')).not.toBeInTheDocument();
79
+ expect(screen.getByText('••••• VND')).toBeInTheDocument();
80
+ fireEvent.click(screen.getByRole('button', { name: 'show_confidential' }));
81
+ expect(mocks.toggleConfidential).toHaveBeenCalledOnce();
82
+ });
83
+ it('requires confirmation before restoring the invoice and payment', () => {
84
+ render(<InvoiceHistory wsId="workspace" canRestore />);
85
+ fireEvent.click(screen.getByRole('button', { name: 'restore_invoice' }));
86
+ expect(screen.getByRole('alertdialog')).toHaveTextContent(
87
+ 'recovery_confirmation'
88
+ );
89
+ expect(mocks.mutate).not.toHaveBeenCalled();
90
+ fireEvent.click(
91
+ screen.getAllByRole('button', { name: 'restore_invoice' }).at(-1)!
92
+ );
93
+ expect(mocks.mutate).toHaveBeenCalledWith('invoice-id');
94
+ });
95
+ it('does not offer restoration without mutation permission', () => {
96
+ render(<InvoiceHistory wsId="workspace" canRestore={false} />);
97
+ expect(
98
+ screen.queryByRole('button', { name: 'restore_invoice' })
99
+ ).not.toBeInTheDocument();
100
+ });
101
+ it('explains older deletions that lack a recovery snapshot', () => {
102
+ mocks.query.mockReturnValue({
103
+ data: { data: [{ ...entry, can_restore: false }] },
104
+ });
105
+ render(<InvoiceHistory wsId="workspace" canRestore />);
106
+ expect(screen.getByText('recovery_unavailable')).toBeInTheDocument();
107
+ expect(
108
+ screen.queryByRole('button', { name: 'restore_invoice' })
109
+ ).not.toBeInTheDocument();
110
+ });
111
+ it('uses server lookahead instead of guessing from a full page', () => {
112
+ mocks.query.mockReturnValue({
113
+ data: {
114
+ data: Array.from({ length: 25 }, (_, id) => ({
115
+ ...entry,
116
+ id: String(id),
117
+ })),
118
+ hasMore: false,
119
+ },
120
+ });
121
+ render(<InvoiceHistory wsId="workspace" canRestore />);
122
+ expect(
123
+ screen.getAllByRole('button', { name: 'activity_next' })[0]!
124
+ ).toBeDisabled();
125
+ });
126
+ it('pages forward and resets to the first page when searching', () => {
127
+ mocks.query.mockReturnValue({ data: { data: [entry], hasMore: true } });
128
+ render(<InvoiceHistory wsId="workspace" canRestore />);
129
+ fireEvent.click(
130
+ screen.getAllByRole('button', { name: 'activity_next' })[0]!
131
+ );
132
+ expect(mocks.query.mock.lastCall?.[0].queryKey[3]).toBe(1);
133
+ fireEvent.change(screen.getByRole('textbox', { name: 'activity_search' }), {
134
+ target: { value: 'Customer' },
135
+ });
136
+ fireEvent.click(
137
+ screen.getByRole('button', { name: 'activity_search_action' })
138
+ );
139
+ expect(mocks.query.mock.lastCall?.[0].queryKey[3]).toBe(0);
140
+ fireEvent.click(
141
+ screen.getByRole('button', { name: 'activity_clear_filters' })
142
+ );
143
+ expect(
144
+ screen.getByRole('textbox', { name: 'activity_search' })
145
+ ).toHaveValue('');
146
+ });
147
+ it('keeps results visible but blocks stale restoration and paging while fetching', () => {
148
+ mocks.query.mockReturnValue({
149
+ data: { data: [entry], hasMore: true },
150
+ isFetching: true,
151
+ isPlaceholderData: true,
152
+ });
153
+ render(<InvoiceHistory wsId="workspace" canRestore />);
154
+ expect(screen.getByText('Customer')).toBeInTheDocument();
155
+ expect(
156
+ screen.getByRole('button', { name: 'restore_invoice' })
157
+ ).toBeDisabled();
158
+ expect(
159
+ screen.getAllByRole('button', { name: 'activity_next' })[0]!
160
+ ).toBeDisabled();
161
+ expect(screen.getByText('activity_loading')).toBeInTheDocument();
162
+ });
163
+ it('offers retry rather than presenting an error as empty history', () => {
164
+ mocks.query.mockReturnValue({ isError: true, refetch: mocks.refetch });
165
+ render(<InvoiceHistory wsId="workspace" canRestore />);
166
+ fireEvent.click(screen.getByRole('button', { name: 'activity_retry' }));
167
+ expect(mocks.refetch).toHaveBeenCalledOnce();
168
+ expect(screen.queryByText('activity_empty')).not.toBeInTheDocument();
169
+ });
170
+ });