@tuturuuu/ui 0.32.0 → 0.33.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 (78) hide show
  1. package/CHANGELOG.md +49 -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/education/modules/resources/pdf-viewer.tsx +13 -2
  23. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  24. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  25. package/src/components/ui/custom/report-preview.tsx +2 -3
  26. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  27. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  28. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  29. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  31. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  33. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  34. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  35. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  36. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  37. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  38. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  39. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  40. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  41. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  42. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  43. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  44. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  45. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  47. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  48. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  49. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  50. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  51. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  52. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  53. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  54. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  55. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  64. package/src/components/ui/public-workspace-prices.tsx +13 -0
  65. package/src/components/ui/pwa-status.test.tsx +13 -2
  66. package/src/components/ui/pwa-status.tsx +15 -3
  67. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  68. package/src/components/ui/text-editor/editor.tsx +11 -3
  69. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  70. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  71. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  72. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  73. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  74. package/src/hooks/calendar-provider-sync.ts +33 -0
  75. package/src/hooks/notification-subscription.ts +74 -0
  76. package/src/hooks/use-calendar-sync.tsx +11 -14
  77. package/src/hooks/use-calendar.tsx +2 -2
  78. package/src/hooks/use-notifications.ts +5 -141
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Check,
5
+ FlaskConical,
6
+ ListFilter,
7
+ SearchX,
8
+ SlidersHorizontal,
9
+ } from '@tuturuuu/icons/lucide';
10
+ import {
11
+ COMPARISON_FEATURES,
12
+ COMPARISON_TIERS,
13
+ type ComparisonTier,
14
+ } from '@tuturuuu/utils/commercial-comparison';
15
+ import { useId, useState } from 'react';
16
+ import { Button } from './button';
17
+ import { ComparisonTable } from './commercial-comparison-table';
18
+ import { ComparisonToolbar } from './commercial-comparison-toolbar';
19
+
20
+ export interface ComparisonCopy {
21
+ title: string;
22
+ description: string;
23
+ search: string;
24
+ allApps: string;
25
+ allCategories: string;
26
+ category: string;
27
+ app: string;
28
+ differences: string;
29
+ internal: string;
30
+ details: string;
31
+ reset: string;
32
+ empty: string;
33
+ results: string;
34
+ feature: string;
35
+ tiers: string;
36
+ pending: string;
37
+ included: string;
38
+ excluded: string;
39
+ preview: string;
40
+ contract: string;
41
+ seats: string;
42
+ internalValue: string;
43
+ features: Record<string, string>;
44
+ apps: Record<string, string>;
45
+ explanations: Record<string, string>;
46
+ categories: Record<string, string>;
47
+ tierNames: Record<ComparisonTier, string>;
48
+ }
49
+
50
+ export function CommercialComparison({ copy }: { copy: ComparisonCopy }) {
51
+ const id = useId();
52
+ const [query, setQuery] = useState('');
53
+ const [app, setApp] = useState('all');
54
+ const [category, setCategory] = useState('all');
55
+ const [differences, setDifferences] = useState(false);
56
+ const [internal, setInternal] = useState(false);
57
+ const [details, setDetails] = useState(false);
58
+ const [tiers, setTiers] = useState<ComparisonTier[]>(COMPARISON_TIERS);
59
+ const normalized = query
60
+ .normalize('NFD')
61
+ .replace(/\p{M}/gu, '')
62
+ .toLowerCase()
63
+ .replace(/đ/g, 'd')
64
+ .trim();
65
+ const visible = COMPARISON_FEATURES.filter((row) => {
66
+ if (!internal && row.category === 'internal') return false;
67
+ if (app !== 'all' && row.app !== app) return false;
68
+ if (category !== 'all' && row.category !== category) return false;
69
+ if (
70
+ differences &&
71
+ new Set(tiers.map((tier) => row.values[COMPARISON_TIERS.indexOf(tier)]))
72
+ .size < 2
73
+ )
74
+ return false;
75
+ const text = [
76
+ copy.features[row.id],
77
+ copy.apps[row.app],
78
+ copy.categories[row.category],
79
+ copy.explanations[row.detail],
80
+ ...row.values.map(valueLabel),
81
+ ]
82
+ .join(' ')
83
+ .normalize('NFD')
84
+ .replace(/\p{M}/gu, '')
85
+ .toLowerCase()
86
+ .replace(/đ/g, 'd');
87
+ return normalized.split(/\s+/).every((word) => text.includes(word));
88
+ });
89
+ const appIds = [
90
+ ...new Set(
91
+ COMPARISON_FEATURES.filter(
92
+ (row) => internal || row.category !== 'internal'
93
+ ).map((row) => row.app)
94
+ ),
95
+ ];
96
+ function reset() {
97
+ setQuery('');
98
+ setApp('all');
99
+ setCategory('all');
100
+ setDifferences(false);
101
+ setDetails(false);
102
+ setInternal(false);
103
+ setTiers(COMPARISON_TIERS);
104
+ }
105
+ function toggleTier(tier: ComparisonTier) {
106
+ setTiers((current) =>
107
+ current.includes(tier)
108
+ ? current.length > 1
109
+ ? current.filter((item) => item !== tier)
110
+ : current
111
+ : COMPARISON_TIERS.filter(
112
+ (item) => item === tier || current.includes(item)
113
+ )
114
+ );
115
+ }
116
+ function valueLabel(value: string) {
117
+ if (value === 'internal') return copy.internalValue;
118
+ if (
119
+ value === 'included' ||
120
+ value === 'excluded' ||
121
+ value === 'preview' ||
122
+ value === 'contract' ||
123
+ value === 'seats'
124
+ )
125
+ return copy[value];
126
+ return value;
127
+ }
128
+ return (
129
+ <section
130
+ aria-labelledby={`${id}-title`}
131
+ className="relative mt-16 min-w-0 scroll-mt-28 space-y-6 sm:mt-24"
132
+ id="compare-plans"
133
+ >
134
+ <div
135
+ className="pointer-events-none absolute -top-8 right-0 h-64 w-2/3 rounded-full bg-dynamic-blue/[0.04] blur-3xl"
136
+ aria-hidden
137
+ />
138
+ <div className="relative flex flex-wrap items-end justify-between gap-6 pb-3">
139
+ <div className="max-w-3xl space-y-4">
140
+ <span className="inline-flex items-center gap-2 font-mono-ui text-[0.65rem] text-dynamic-blue uppercase tracking-[0.18em]">
141
+ <SlidersHorizontal aria-hidden className="size-3.5" />
142
+ {copy.tiers}
143
+ </span>
144
+ <h3
145
+ className="text-balance font-display font-semibold text-3xl leading-[1.1] tracking-[-0.035em] sm:text-4xl lg:text-5xl"
146
+ id={`${id}-title`}
147
+ >
148
+ {copy.title}
149
+ </h3>
150
+ <p className="max-w-xl text-foreground/55 text-sm leading-relaxed sm:text-base">
151
+ {copy.description}
152
+ </p>
153
+ </div>
154
+ </div>
155
+ <ComparisonToolbar
156
+ copy={copy}
157
+ query={query}
158
+ app={app}
159
+ category={category}
160
+ differences={differences}
161
+ internal={internal}
162
+ details={details}
163
+ tiers={tiers}
164
+ appIds={appIds}
165
+ onQuery={setQuery}
166
+ onApp={setApp}
167
+ onCategory={setCategory}
168
+ onDifferences={setDifferences}
169
+ onDetails={setDetails}
170
+ onTier={toggleTier}
171
+ onReset={reset}
172
+ onInternal={(value) => {
173
+ setInternal(value);
174
+ setApp('all');
175
+ setCategory('all');
176
+ }}
177
+ />
178
+ <div className="flex flex-wrap items-center justify-between gap-3 px-1">
179
+ <p
180
+ aria-live="polite"
181
+ className="inline-flex items-center gap-2 text-foreground/55 text-xs"
182
+ >
183
+ <ListFilter aria-hidden className="size-3.5 text-dynamic-blue" />
184
+ {visible.length} {copy.results}
185
+ </p>
186
+ <div className="flex flex-wrap items-center gap-4 text-[0.65rem] text-foreground/50">
187
+ <span className="inline-flex items-center gap-1.5">
188
+ <Check aria-hidden className="size-3.5 text-dynamic-green" />
189
+ {copy.included}
190
+ </span>
191
+ <span className="inline-flex items-center gap-1.5">
192
+ <FlaskConical
193
+ aria-hidden
194
+ className="size-3.5 text-dynamic-orange"
195
+ />
196
+ {copy.preview}
197
+ </span>
198
+ </div>
199
+ </div>
200
+ {visible.length === 0 ? (
201
+ <div className="flex flex-col items-center rounded-2xl border border-foreground/10 border-dashed bg-foreground/[0.02] px-6 py-16 text-center">
202
+ <SearchX aria-hidden className="mb-4 size-8 text-foreground/30" />
203
+ <p className="text-foreground/65 text-sm">{copy.empty}</p>
204
+ <Button
205
+ variant="outline"
206
+ className="mt-5 rounded-full"
207
+ onClick={reset}
208
+ >
209
+ {copy.reset}
210
+ </Button>
211
+ </div>
212
+ ) : (
213
+ <ComparisonTable
214
+ copy={copy}
215
+ visible={visible}
216
+ tiers={tiers}
217
+ appIds={appIds}
218
+ details={details}
219
+ valueLabel={valueLabel}
220
+ />
221
+ )}
222
+ </section>
223
+ );
224
+ }
@@ -0,0 +1,32 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+ import { ComparisonValue } from './comparison-value';
4
+
5
+ it('keeps a status icon compact and reveals its wording and scope on tap', async () => {
6
+ render(
7
+ <ComparisonValue
8
+ value="included"
9
+ label="Included"
10
+ context="Plus: Tasks"
11
+ explanation="Within your workspace limits"
12
+ />
13
+ );
14
+ const button = screen.getByRole('button', { name: 'Plus: Tasks: Included' });
15
+ expect(button.textContent).toBe('');
16
+ fireEvent.click(button);
17
+ await waitFor(() =>
18
+ expect(screen.getByRole('tooltip').textContent).toContain(
19
+ 'Within your workspace limits'
20
+ )
21
+ );
22
+ fireEvent.keyDown(button, { key: 'Escape' });
23
+ await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull());
24
+ });
25
+ it('keeps numeric quotas visible instead of hiding the limit in an icon', () => {
26
+ render(
27
+ <ComparisonValue value="20 GiB" label="20 GiB" context="Plus: Drive" />
28
+ );
29
+ expect(
30
+ screen.getByRole('button', { name: 'Plus: Drive: 20 GiB' }).textContent
31
+ ).toBe('20 GiB');
32
+ });
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Check,
5
+ Clock3,
6
+ FileText,
7
+ FlaskConical,
8
+ Gauge,
9
+ Info,
10
+ Minus,
11
+ Server,
12
+ Users,
13
+ } from '@tuturuuu/icons/lucide';
14
+ import { useState } from 'react';
15
+ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
16
+
17
+ const statusIcons = {
18
+ info: Info,
19
+ included: Check,
20
+ excluded: Minus,
21
+ preview: FlaskConical,
22
+ contract: FileText,
23
+ seats: Users,
24
+ internal: Server,
25
+ pending: Clock3,
26
+ limited: Gauge,
27
+ };
28
+
29
+ /** Keep quantities visible; status wording and scope stay accessible on focus or tap. */
30
+ export function ComparisonValue({
31
+ value,
32
+ label,
33
+ context,
34
+ explanation,
35
+ }: {
36
+ value: string;
37
+ label: string;
38
+ context: string;
39
+ explanation?: string;
40
+ }) {
41
+ const [open, setOpen] = useState(false);
42
+ const Icon = statusIcons[value as keyof typeof statusIcons];
43
+ return (
44
+ <Tooltip open={open} onOpenChange={setOpen}>
45
+ <TooltipTrigger asChild>
46
+ <button
47
+ type="button"
48
+ aria-label={`${context}: ${label}`}
49
+ className={`inline-flex min-h-8 min-w-8 items-center justify-center rounded-md px-1.5 align-middle transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring ${value === 'excluded' ? 'text-foreground/25' : value === 'included' ? 'bg-dynamic-green/[0.08] text-dynamic-green' : value === 'preview' || value === 'pending' ? 'bg-dynamic-orange/[0.08] text-dynamic-orange' : value === 'info' ? 'text-foreground/35' : 'font-mono-ui text-foreground/80 text-xs tabular-nums'}`}
50
+ onClick={(event) => {
51
+ event.preventDefault();
52
+ setOpen(!open);
53
+ }}
54
+ >
55
+ {Icon ? (
56
+ <Icon
57
+ aria-hidden="true"
58
+ className="size-4"
59
+ strokeWidth={value === 'included' ? 2.5 : 1.75}
60
+ />
61
+ ) : (
62
+ label
63
+ )}
64
+ </button>
65
+ </TooltipTrigger>
66
+ <TooltipContent className="max-w-72 space-y-1 text-left">
67
+ <p className="font-semibold">{label}</p>
68
+ {explanation && <p className="leading-relaxed">{explanation}</p>}
69
+ </TooltipContent>
70
+ </Tooltip>
71
+ );
72
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ paginateReportBlocks,
4
+ parseReportSegments,
5
+ } from '../report-preview-pagination';
6
+
7
+ describe.each([20, 200])(
8
+ 'report pagination progress with later page height %i',
9
+ (laterPageHeight) => {
10
+ it.each([
11
+ 'Short feedback',
12
+ 'Long feedback with spaces. '.repeat(12),
13
+ 'x'.repeat(150),
14
+ '- First bullet\n- Second bullet that also cannot fit',
15
+ ])('finishes and preserves every character: %s', (text) => {
16
+ const segments = parseReportSegments(text, 'feedback');
17
+ let measurements = 0;
18
+ const pages = paginateReportBlocks({
19
+ blocks: [
20
+ {
21
+ key: 'feedback',
22
+ kind: 'rich-text',
23
+ splittable: true,
24
+ title: 'Feedback',
25
+ tone: 'neutral',
26
+ segments,
27
+ },
28
+ ],
29
+ firstPageHeight: 1,
30
+ laterPageHeight,
31
+ pageGap: 16,
32
+ measureBlock: () => {
33
+ // Fail deterministically instead of hanging the test runner if the
34
+ // fallback retries the same unconsumed segment forever.
35
+ if (++measurements > 500)
36
+ throw new Error('Pagination made no progress');
37
+ return 100;
38
+ },
39
+ });
40
+
41
+ const rendered = pages
42
+ .flatMap((page) => page.blocks)
43
+ .flatMap((block) => block.segments);
44
+ for (const source of segments) {
45
+ expect(
46
+ rendered
47
+ .filter((segment) => segment.id === source.id)
48
+ .map((segment) => segment.text)
49
+ .join('')
50
+ ).toBe(source.text);
51
+ }
52
+ expect(pages.length).toBeLessThanOrEqual(text.length);
53
+ });
54
+ }
55
+ );
56
+
57
+ it.each([0, 1, 20, 60, 200])(
58
+ 'preserves mixed report sections with a %i-pixel page budget',
59
+ (pageHeight) => {
60
+ const content = parseReportSegments('Tiến bộ tốt. '.repeat(20), 'content');
61
+ const feedback = parseReportSegments('- Review\n\n- Practice', 'feedback');
62
+ let measurements = 0;
63
+ const pages = paginateReportBlocks({
64
+ blocks: [
65
+ {
66
+ key: 'empty',
67
+ kind: 'rich-text',
68
+ splittable: true,
69
+ title: 'Empty',
70
+ tone: 'neutral',
71
+ segments: [],
72
+ },
73
+ {
74
+ key: 'content',
75
+ kind: 'rich-text',
76
+ splittable: true,
77
+ title: 'Content',
78
+ tone: 'primary',
79
+ segments: content,
80
+ },
81
+ {
82
+ key: 'feedback',
83
+ kind: 'rich-text',
84
+ splittable: true,
85
+ title: 'Feedback',
86
+ tone: 'neutral',
87
+ segments: feedback,
88
+ },
89
+ {
90
+ key: 'closing',
91
+ kind: 'atomic',
92
+ splittable: false,
93
+ title: 'Closing',
94
+ tone: 'accent',
95
+ segments: parseReportSegments('Thank you', 'closing'),
96
+ },
97
+ ],
98
+ firstPageHeight: pageHeight,
99
+ laterPageHeight: pageHeight,
100
+ pageGap: 16,
101
+ measureBlock: ({ segments }) => {
102
+ if (++measurements > 1000)
103
+ throw new Error('Pagination made no progress');
104
+ return (
105
+ 40 +
106
+ segments.reduce((height, segment) => height + segment.text.length, 0)
107
+ );
108
+ },
109
+ });
110
+ const output = pages
111
+ .flatMap((page) => page.blocks)
112
+ .flatMap((block) => block.segments);
113
+ for (const segment of [...content, ...feedback].filter(
114
+ (entry) => entry.kind !== 'spacer'
115
+ )) {
116
+ expect(
117
+ output
118
+ .filter((entry) => entry.id === segment.id)
119
+ .map((entry) => entry.text)
120
+ .join('')
121
+ ).toBe(segment.text);
122
+ }
123
+ expect(
124
+ output
125
+ .filter((segment) => segment.id === 'closing-paragraph-0')
126
+ .map((segment) => segment.text)
127
+ ).toEqual(['Thank you']);
128
+ }
129
+ );
@@ -0,0 +1,56 @@
1
+ import { act, render } from '@testing-library/react';
2
+ import { afterEach, expect, it, vi } from 'vitest';
3
+ import { ReportPreviewViewport } from '../report-preview-viewport';
4
+
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ vi.unstubAllGlobals();
8
+ });
9
+ it('fits the visible sheet when the panel shrinks and never enlarges A4', () => {
10
+ let width = 400;
11
+ let resize = () => {};
12
+ const disconnect = vi.fn();
13
+ vi.stubGlobal(
14
+ 'ResizeObserver',
15
+ class {
16
+ constructor(callback: () => void) {
17
+ resize = callback;
18
+ }
19
+ observe() {}
20
+ disconnect = disconnect;
21
+ }
22
+ );
23
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(
24
+ () => width
25
+ );
26
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800);
27
+ const { container, unmount } = render(
28
+ <ReportPreviewViewport>
29
+ <div>Report</div>
30
+ </ReportPreviewViewport>
31
+ );
32
+ const sheet = container.querySelector('[style]') as HTMLElement;
33
+ expect(sheet.style.zoom).toBe('0.5');
34
+ width = 320;
35
+ act(() => resize());
36
+ expect(sheet.style.zoom).toBe('0.4');
37
+ width = 1000;
38
+ act(() => resize());
39
+ expect(sheet.style.zoom).toBe('1');
40
+ unmount();
41
+ expect(disconnect).toHaveBeenCalledOnce();
42
+ });
43
+
44
+ it('renders and performs the initial fit without ResizeObserver', () => {
45
+ vi.stubGlobal('ResizeObserver', undefined);
46
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400);
47
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800);
48
+ const { container } = render(
49
+ <ReportPreviewViewport>
50
+ <div>Report</div>
51
+ </ReportPreviewViewport>
52
+ );
53
+ expect((container.querySelector('[style]') as HTMLElement).style.zoom).toBe(
54
+ '0.5'
55
+ );
56
+ });
@@ -12,7 +12,13 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
12
12
  `https://unpkg.com/pdfjs-dist@${pdfjs.version}/legacy/build/pdf.worker.min.mjs`
13
13
  ).toString();
14
14
 
15
- export function PDFViewer({ url }: { url: string }) {
15
+ export function PDFViewer({
16
+ onLoadError,
17
+ url,
18
+ }: {
19
+ onLoadError?: () => void;
20
+ url: string;
21
+ }) {
16
22
  const t = useTranslations();
17
23
  const containerRef = useRef<HTMLDivElement>(null);
18
24
 
@@ -47,7 +53,12 @@ export function PDFViewer({ url }: { url: string }) {
47
53
 
48
54
  return (
49
55
  <div ref={containerRef} className="relative">
50
- <Document file={url} onLoadSuccess={onDocumentLoadSuccess}>
56
+ <Document
57
+ suspense={false}
58
+ file={url}
59
+ onLoadError={onLoadError}
60
+ onLoadSuccess={onDocumentLoadSuccess}
61
+ >
51
62
  <AnimatePresence mode="wait">
52
63
  <motion.div
53
64
  key={pageNumber}
@@ -478,14 +478,16 @@ export function paginateReportBlocks({
478
478
  Math.max(1, refineSplitBoundary(forcedSegment.text, 48))
479
479
  )
480
480
  );
481
+ // Consume the head we actually emitted. When nothing fits, the split
482
+ // result retains the entire source as its tail; reusing that tail here
483
+ // would repeat the same segment forever and freeze the report editor.
481
484
  const tailSegment =
482
- forcedSplit.tailSegment ??
483
- (headSegment.text.length < forcedSegment.text.length
485
+ headSegment.text.length < forcedSegment.text.length
484
486
  ? cloneSegment(
485
487
  forcedSegment,
486
488
  forcedSegment.text.slice(headSegment.text.length)
487
489
  )
488
- : null);
490
+ : null;
489
491
  const forcedHeight =
490
492
  forcedSplit.height ||
491
493
  measureBlock({
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
4
+
5
+ /** Scale only the on-screen sheet; export and pagination measurement stay at A4. */
6
+ export function ReportPreviewViewport({ children }: { children: ReactNode }) {
7
+ const container = useRef<HTMLDivElement>(null);
8
+ const sheet = useRef<HTMLDivElement>(null);
9
+ const [scale, setScale] = useState(1);
10
+ useEffect(() => {
11
+ const parent = container.current;
12
+ const page = sheet.current;
13
+ if (!parent || !page) return;
14
+ const resize = () => {
15
+ const width = page.offsetWidth;
16
+ if (width > 0 && parent.clientWidth > 0) {
17
+ setScale(Math.min(1, parent.clientWidth / width));
18
+ }
19
+ };
20
+ resize();
21
+ if (typeof ResizeObserver === 'undefined') return;
22
+ const observer = new ResizeObserver(resize);
23
+ observer.observe(parent);
24
+ return () => observer.disconnect();
25
+ }, []);
26
+ return (
27
+ <div
28
+ ref={container}
29
+ className="w-full min-w-0 overflow-hidden print:hidden"
30
+ >
31
+ <div
32
+ ref={sheet}
33
+ className="mx-auto flex w-[210mm] flex-col gap-6"
34
+ style={{ zoom: scale }}
35
+ >
36
+ {children}
37
+ </div>
38
+ </div>
39
+ );
40
+ }
@@ -20,6 +20,7 @@ import {
20
20
  type ReportFlowSegment,
21
21
  type ReportSectionTone,
22
22
  } from './report-preview-pagination';
23
+ import { ReportPreviewViewport } from './report-preview-viewport';
23
24
 
24
25
  const PAGE_HEIGHT_MM = 297;
25
26
  const PAGE_WIDTH_MM = 210;
@@ -763,9 +764,7 @@ export default function ReportPreview({
763
764
 
764
765
  {singlePagePreview ? (
765
766
  <>
766
- <div className="mx-auto flex w-[210mm] min-w-[210mm] flex-col gap-6 print:hidden">
767
- {visiblePreviewPage}
768
- </div>
767
+ <ReportPreviewViewport>{visiblePreviewPage}</ReportPreviewViewport>
769
768
  {paginationReady ? (
770
769
  <div className="pointer-events-none fixed top-0 -left-[99999px] z-[-1]">
771
770
  <div
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { Receipt, Tags } from '@tuturuuu/icons';
3
4
  import FeatureSummary from '@tuturuuu/ui/custom/feature-summary';
4
5
  import { CategoryBreakdownChart } from '@tuturuuu/ui/finance/shared/charts/category-breakdown-chart';
5
6
  import { TagManager } from '@tuturuuu/ui/finance/tags/tag-manager';
@@ -58,9 +59,11 @@ export default function CategoriesTagsTabs({
58
59
  >
59
60
  <TabsList>
60
61
  <TabsTrigger value="categories">
62
+ <Receipt className="size-4" />
61
63
  {t('ws-transaction-categories.plural')}
62
64
  </TabsTrigger>
63
65
  <TabsTrigger value="tags">
66
+ <Tags className="size-4" />
64
67
  {t('ws-transaction-tags.plural')}
65
68
  </TabsTrigger>
66
69
  </TabsList>
@@ -30,6 +30,7 @@ type DeleteInvoiceAction = (
30
30
 
31
31
  interface InvoiceExtraData {
32
32
  canDeleteInvoices?: boolean;
33
+ canRestoreInvoices?: boolean;
33
34
  deleteInvoiceAction?: DeleteInvoiceAction;
34
35
  currency?: string;
35
36
  }
@@ -359,6 +360,7 @@ export const invoiceColumns = ({
359
360
  row={row}
360
361
  href={row.original.href}
361
362
  canDeleteInvoices={extraData?.canDeleteInvoices}
363
+ canRestoreInvoices={extraData?.canRestoreInvoices}
362
364
  deleteInvoiceAction={extraData?.deleteInvoiceAction}
363
365
  />
364
366
  ),
@@ -38,6 +38,8 @@ export function InvoiceCheckoutSummary({
38
38
  currency = 'VND',
39
39
  }: InvoiceCheckoutSummaryProps) {
40
40
  const t = useTranslations();
41
+ const displayedDiscount =
42
+ discountAmount ?? Math.max(0, subtotal - totalBeforeRounding);
41
43
  return (
42
44
  <div className="space-y-4">
43
45
  <div className="space-y-2">
@@ -51,15 +53,16 @@ export function InvoiceCheckoutSummary({
51
53
  />
52
54
  </div>
53
55
 
54
- {discountAmount !== undefined && discountLabel && (
56
+ {displayedDiscount > 0 && (
55
57
  <div className="flex justify-between">
56
58
  <span className="text-muted-foreground">
57
- {t('ws-invoices.discount')} ({discountLabel})
59
+ {t('ws-invoices.discount')}
60
+ {discountLabel ? ` (${discountLabel})` : ''}
58
61
  </span>
59
62
  <FinanceDisplayAmount
60
63
  alwaysShow
61
64
  className={discountClassName}
62
- value={`-${formatCurrency(discountAmount, currency)}`}
65
+ value={`-${formatCurrency(displayedDiscount, currency)}`}
63
66
  />
64
67
  </div>
65
68
  )}