@tuturuuu/ui 0.31.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 (79) hide show
  1. package/CHANGELOG.md +57 -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-markdown.tsx +17 -2
  11. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  12. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  13. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  14. package/src/components/ui/commercial-comparison-style.ts +104 -0
  15. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  16. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  17. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  18. package/src/components/ui/commercial-comparison.tsx +224 -0
  19. package/src/components/ui/comparison-value.test.tsx +32 -0
  20. package/src/components/ui/comparison-value.tsx +72 -0
  21. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  22. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  23. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  24. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  25. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  26. package/src/components/ui/custom/report-preview.tsx +2 -3
  27. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  28. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  29. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  31. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  33. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  34. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  35. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  36. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  37. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  38. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  39. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  40. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  41. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  42. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  43. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  44. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  45. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  47. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  48. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  49. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  50. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  51. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  52. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  53. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  54. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  55. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  64. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  65. package/src/components/ui/public-workspace-prices.tsx +13 -0
  66. package/src/components/ui/pwa-status.test.tsx +13 -2
  67. package/src/components/ui/pwa-status.tsx +15 -3
  68. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  69. package/src/components/ui/text-editor/editor.tsx +11 -3
  70. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  71. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  72. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  73. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  74. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  75. package/src/hooks/calendar-provider-sync.ts +33 -0
  76. package/src/hooks/notification-subscription.ts +74 -0
  77. package/src/hooks/use-calendar-sync.tsx +11 -14
  78. package/src/hooks/use-calendar.tsx +2 -2
  79. package/src/hooks/use-notifications.ts +5 -141
@@ -0,0 +1,300 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { History, Loader2, RotateCcw, Search } from '@tuturuuu/icons';
5
+ import {
6
+ getInvoiceHistory,
7
+ type InvoiceHistoryEntry,
8
+ restoreInvoice,
9
+ } from '@tuturuuu/internal-api/finance';
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogAction,
13
+ AlertDialogCancel,
14
+ AlertDialogContent,
15
+ AlertDialogDescription,
16
+ AlertDialogFooter,
17
+ AlertDialogHeader,
18
+ AlertDialogTitle,
19
+ } from '@tuturuuu/ui/alert-dialog';
20
+ import { Button } from '@tuturuuu/ui/button';
21
+ import { Input } from '@tuturuuu/ui/input';
22
+ import { Skeleton } from '@tuturuuu/ui/skeleton';
23
+ import { toast } from '@tuturuuu/ui/sonner';
24
+ import { useRouter } from 'next/navigation';
25
+ import { useTranslations } from 'next-intl';
26
+ import { useState } from 'react';
27
+ import { FinanceNumbersVisibilityToggle } from '../shared/numbers-visibility-toggle';
28
+ import {
29
+ DEFAULT_HISTORY_FILTERS,
30
+ InvoiceHistoryFilterBar,
31
+ } from './invoice-history-filters';
32
+ import { InvoiceHistoryPagination } from './invoice-history-pagination';
33
+ import { InvoiceHistoryRow } from './invoice-history-row';
34
+ import { invalidateInvoiceMutationQueries } from './query-invalidation';
35
+
36
+ export function InvoiceHistory({
37
+ wsId,
38
+ canRestore,
39
+ }: {
40
+ wsId: string;
41
+ canRestore: boolean;
42
+ }) {
43
+ const t = useTranslations('ws-invoices');
44
+ const router = useRouter();
45
+ const queryClient = useQueryClient();
46
+ const [filters, setFilters] = useState(DEFAULT_HISTORY_FILTERS);
47
+ const [deletedOnly, setDeletedOnly] = useState(true);
48
+ const [page, setPage] = useState(0);
49
+ const [pageSize, setPageSize] = useState(25);
50
+ const [timeZone] = useState(
51
+ () => Intl.DateTimeFormat().resolvedOptions().timeZone
52
+ );
53
+ const [search, setSearch] = useState('');
54
+ const [submittedSearch, setSubmittedSearch] = useState('');
55
+ const [selected, setSelected] = useState<InvoiceHistoryEntry | null>(null);
56
+ const query = useQuery({
57
+ queryKey: [
58
+ 'invoice-history',
59
+ wsId,
60
+ deletedOnly,
61
+ page,
62
+ pageSize,
63
+ submittedSearch,
64
+ filters,
65
+ ],
66
+ placeholderData: (previous, previousQuery) =>
67
+ previousQuery?.queryKey[1] === wsId ? previous : undefined,
68
+ staleTime: 30_000,
69
+ retry: 1,
70
+ queryFn: () =>
71
+ getInvoiceHistory(wsId, {
72
+ q: submittedSearch,
73
+ entity: filters.entity === 'all' ? undefined : filters.entity,
74
+ action: filters.action === 'all' ? undefined : filters.action,
75
+ from: filters.from
76
+ ? new Date(`${filters.from}T00:00:00`).toISOString()
77
+ : undefined,
78
+ to: filters.to
79
+ ? new Date(`${filters.to}T23:59:59.999`).toISOString()
80
+ : undefined,
81
+ sort: filters.sort,
82
+ deletedOnly,
83
+ offset: page * pageSize,
84
+ limit: pageSize,
85
+ }),
86
+ });
87
+ const restore = useMutation({
88
+ mutationFn: (invoiceId: string) => restoreInvoice(wsId, invoiceId),
89
+ onSuccess: async () => {
90
+ setSelected(null);
91
+ toast.success(t('recovery_success'));
92
+ await invalidateInvoiceMutationQueries(queryClient, wsId);
93
+ router.refresh();
94
+ },
95
+ onError: () => toast.error(t('recovery_error')),
96
+ });
97
+
98
+ const resetFilters = () => {
99
+ setFilters(DEFAULT_HISTORY_FILTERS);
100
+ setSearch('');
101
+ setSubmittedSearch('');
102
+ setPage(0);
103
+ };
104
+ const hasFilters = !!(
105
+ submittedSearch ||
106
+ filters.entity !== 'all' ||
107
+ filters.action !== 'all' ||
108
+ filters.from ||
109
+ filters.to ||
110
+ filters.sort !== 'desc'
111
+ );
112
+
113
+ const pagination = (
114
+ <InvoiceHistoryPagination
115
+ page={page}
116
+ pageSize={pageSize}
117
+ hasMore={!!query.data?.hasMore}
118
+ busy={query.isFetching}
119
+ failed={query.isError}
120
+ onPageChange={setPage}
121
+ onPageSizeChange={(size) => {
122
+ setPageSize(size);
123
+ setPage(0);
124
+ }}
125
+ />
126
+ );
127
+
128
+ return (
129
+ <section className="space-y-4" aria-label={t('activity')}>
130
+ <div className="flex flex-wrap items-start justify-between gap-3">
131
+ <div className="max-w-2xl space-y-1">
132
+ <h2 className="flex items-center gap-2 font-semibold">
133
+ <History className="size-4" />
134
+ {t('activity')}
135
+ </h2>
136
+ <p className="text-muted-foreground text-sm">
137
+ {t('activity_description')}
138
+ </p>
139
+ </div>
140
+ <div className="flex flex-wrap items-center gap-2">
141
+ <FinanceNumbersVisibilityToggle />
142
+ <Button
143
+ variant="outline"
144
+ size="icon"
145
+ aria-label={t('activity_refresh')}
146
+ title={t('activity_refresh')}
147
+ disabled={query.isFetching}
148
+ onClick={() => query.refetch()}
149
+ >
150
+ <RotateCcw className="size-4" />
151
+ </Button>
152
+ <Button
153
+ variant={deletedOnly ? 'secondary' : 'outline'}
154
+ aria-pressed={deletedOnly}
155
+ onClick={() => {
156
+ setDeletedOnly(!deletedOnly);
157
+ if (!deletedOnly)
158
+ setFilters({ ...filters, entity: 'all', action: 'all' });
159
+ setPage(0);
160
+ }}
161
+ >
162
+ {t(deletedOnly ? 'activity_show_all' : 'deleted_invoices')}
163
+ </Button>
164
+ </div>
165
+ </div>
166
+ {deletedOnly && !query.isError && !!query.data?.data.length && (
167
+ <div className="space-y-1 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
168
+ <h3 className="font-medium text-sm">{t('recovery_review_title')}</h3>
169
+ <p className="text-muted-foreground text-sm">
170
+ {t('recovery_review_description')}
171
+ </p>
172
+ </div>
173
+ )}
174
+ <form
175
+ className="flex flex-wrap gap-2"
176
+ onSubmit={(event) => {
177
+ event.preventDefault();
178
+ setSubmittedSearch(search.trim());
179
+ setPage(0);
180
+ }}
181
+ >
182
+ <Input
183
+ className="min-w-48 flex-1"
184
+ value={search}
185
+ maxLength={120}
186
+ onChange={(event) => setSearch(event.target.value)}
187
+ aria-label={t('activity_search')}
188
+ placeholder={t('activity_search')}
189
+ />
190
+ <Button variant="outline" type="submit">
191
+ <Search className="size-4" aria-hidden="true" />
192
+ {t('activity_search_action')}
193
+ </Button>
194
+ {hasFilters && (
195
+ <Button type="button" variant="ghost" onClick={resetFilters}>
196
+ {t('activity_clear_filters')}
197
+ </Button>
198
+ )}
199
+ </form>
200
+ <InvoiceHistoryFilterBar
201
+ value={filters}
202
+ onChange={(next) => {
203
+ setFilters(next);
204
+ if (next.entity !== filters.entity || next.action !== filters.action)
205
+ setDeletedOnly(false);
206
+ setPage(0);
207
+ }}
208
+ />
209
+ {pagination}
210
+ <div
211
+ className="flex min-h-5 flex-wrap items-center justify-between gap-2 text-muted-foreground text-xs"
212
+ role="status"
213
+ aria-live="polite"
214
+ >
215
+ <span>{t('activity_timezone', { timeZone })}</span>
216
+ {query.isFetching && (
217
+ <span className="flex items-center gap-1.5">
218
+ <Loader2 className="size-3 animate-spin" aria-hidden="true" />
219
+ {t('activity_loading')}
220
+ </span>
221
+ )}
222
+ </div>
223
+ {query.isPending ? (
224
+ <div role="status" aria-busy="true">
225
+ <span className="sr-only">{t('loading')}</span>
226
+ {[0, 1, 2].map((index) => (
227
+ <Skeleton key={index} className="mb-2 h-32 w-full" />
228
+ ))}
229
+ </div>
230
+ ) : query.isError ? (
231
+ <div role="alert" className="space-y-3 rounded-lg border p-6">
232
+ <p>{t('activity_error')}</p>
233
+ <Button variant="outline" onClick={() => query.refetch()}>
234
+ {t('activity_retry')}
235
+ </Button>
236
+ </div>
237
+ ) : !query.data.data.length ? (
238
+ <div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground text-sm">
239
+ <History
240
+ className="mx-auto mb-3 size-8 opacity-50"
241
+ aria-hidden="true"
242
+ />
243
+ <p>{t('activity_empty')}</p>
244
+ {hasFilters && (
245
+ <Button variant="link" onClick={resetFilters}>
246
+ {t('activity_clear_filters')}
247
+ </Button>
248
+ )}
249
+ </div>
250
+ ) : (
251
+ <ol
252
+ className={`divide-y rounded-lg border transition-opacity ${query.isPlaceholderData ? 'opacity-60' : ''}`}
253
+ aria-busy={query.isFetching}
254
+ >
255
+ {query.data.data.map((entry) => (
256
+ <InvoiceHistoryRow
257
+ key={entry.id}
258
+ entry={entry}
259
+ timeZone={timeZone}
260
+ canRestore={canRestore}
261
+ restoring={restore.isPending || query.isPlaceholderData}
262
+ onRestore={() => setSelected(entry)}
263
+ />
264
+ ))}
265
+ </ol>
266
+ )}
267
+ {pagination}
268
+ <AlertDialog
269
+ open={!!selected}
270
+ onOpenChange={(open) => {
271
+ if (!open && !restore.isPending) setSelected(null);
272
+ }}
273
+ >
274
+ <AlertDialogContent>
275
+ <AlertDialogHeader>
276
+ <AlertDialogTitle>{t('restore_invoice')}</AlertDialogTitle>
277
+ <AlertDialogDescription>
278
+ {t('recovery_confirmation')}
279
+ </AlertDialogDescription>
280
+ </AlertDialogHeader>
281
+ <p className="break-all font-mono text-xs">{selected?.invoice_id}</p>
282
+ <AlertDialogFooter>
283
+ <AlertDialogCancel disabled={restore.isPending}>
284
+ {t('recovery_cancel')}
285
+ </AlertDialogCancel>
286
+ <AlertDialogAction
287
+ disabled={restore.isPending}
288
+ onClick={(event) => {
289
+ event.preventDefault();
290
+ if (selected) restore.mutate(selected.invoice_id);
291
+ }}
292
+ >
293
+ {t(restore.isPending ? 'recovery_restoring' : 'restore_invoice')}
294
+ </AlertDialogAction>
295
+ </AlertDialogFooter>
296
+ </AlertDialogContent>
297
+ </AlertDialog>
298
+ </section>
299
+ );
300
+ }
@@ -1,4 +1,4 @@
1
- import { FileCheck2, Plus } from '@tuturuuu/icons';
1
+ import { FileCheck2, History, Plus } from '@tuturuuu/icons';
2
2
  import {
3
3
  createAdminClient,
4
4
  createClient,
@@ -25,6 +25,7 @@ import { Skeleton } from '../../skeleton';
25
25
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../tabs';
26
26
  import { InvoiceTotalsChartSkeleton } from './charts/invoice-totals-chart';
27
27
  import { InvoiceAnalytics } from './invoice-analytics';
28
+ import { InvoiceHistory } from './invoice-history';
28
29
  import { InvoicesTable } from './invoices-table';
29
30
  import { PendingInvoicesTab } from './pending-invoices-tab';
30
31
  import { PendingInvoicesTable } from './pending-invoices-table';
@@ -245,7 +246,7 @@ export default async function InvoicesPage({
245
246
  </Suspense>
246
247
 
247
248
  <Tabs defaultValue="created" className="w-full">
248
- <TabsList className="mb-4">
249
+ <TabsList className="mb-4 h-auto flex-wrap">
249
250
  <TabsTrigger value="created" className="gap-2">
250
251
  <FileCheck2 className="h-4 w-4" />
251
252
  {t('ws-invoices.created_invoices')}
@@ -254,12 +255,19 @@ export default async function InvoicesPage({
254
255
  wsId={wsId}
255
256
  label={t('ws-invoices.pending_invoices')}
256
257
  />
258
+ {containsPermission('manage_workspace_audit_logs') && (
259
+ <TabsTrigger value="activity" className="gap-2">
260
+ <History className="size-4" aria-hidden="true" />
261
+ {t('ws-invoices.activity')}
262
+ </TabsTrigger>
263
+ )}
257
264
  </TabsList>
258
265
  <TabsContent value="created">
259
266
  <Suspense fallback={<Skeleton className="h-125 w-full" />}>
260
267
  <InvoicesTable
261
268
  wsId={wsId}
262
269
  canDeleteInvoices={canDeleteInvoices}
270
+ canRestoreInvoices={canCreateInvoices && canDeleteInvoices}
263
271
  canExport={canExportFinanceData}
264
272
  deleteInvoiceAction={deleteInvoiceAction}
265
273
  initialData={initialData}
@@ -276,6 +284,14 @@ export default async function InvoicesPage({
276
284
  />
277
285
  </Suspense>
278
286
  </TabsContent>
287
+ {containsPermission('manage_workspace_audit_logs') && (
288
+ <TabsContent value="activity">
289
+ <InvoiceHistory
290
+ wsId={wsId}
291
+ canRestore={canCreateInvoices && canDeleteInvoices}
292
+ />
293
+ </TabsContent>
294
+ )}
279
295
  </Tabs>
280
296
  </>
281
297
  );
@@ -8,6 +8,7 @@ import { Separator } from '@tuturuuu/ui/separator';
8
8
  import { formatCurrency } from '@tuturuuu/utils/format';
9
9
  import { useTranslations } from 'next-intl';
10
10
  import { FinanceDisplayAmount } from '../../shared/finance-display-amount';
11
+ import { getSavedInvoiceDiscount } from '../invoice-discount';
11
12
 
12
13
  export function FullInvoiceTemplate({
13
14
  invoice,
@@ -54,12 +55,7 @@ export function FullInvoiceTemplate({
54
55
  return total + product.price * product.amount;
55
56
  }, 0);
56
57
 
57
- const discount_amount = promotions.reduce((total, promo) => {
58
- if (promo.use_ratio) {
59
- return total + (subtotal * promo.value) / 100;
60
- }
61
- return total + promo.value;
62
- }, 0);
58
+ const discount_amount = getSavedInvoiceDiscount(products, invoice.price);
63
59
 
64
60
  return (
65
61
  <>
@@ -266,7 +262,7 @@ export function FullInvoiceTemplate({
266
262
  })}
267
263
  />
268
264
  </p>
269
- {promotions.length > 0 && (
265
+ {discount_amount > 0 && (
270
266
  <p
271
267
  className={`mb-2 ${isDarkPreview ? 'text-foreground/70' : 'text-black'}`}
272
268
  >
@@ -1,3 +1,4 @@
1
+ import { render, screen } from '@testing-library/react';
1
2
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
3
  import InvoiceDetailsPage from './invoice-details-page.js';
3
4
 
@@ -10,6 +11,7 @@ type QueryOperation = {
10
11
 
11
12
  const mocks = vi.hoisted(() => ({
12
13
  createAdminClient: vi.fn(),
14
+ omitPromotions: false,
13
15
  invoice: null as Record<string, unknown> | null,
14
16
  notFound: vi.fn(() => {
15
17
  throw new Error('not-found');
@@ -47,6 +49,7 @@ function resultForTable(table: string) {
47
49
  }
48
50
 
49
51
  if (table === 'finance_invoice_promotions') {
52
+ if (mocks.omitPromotions) return { data: [], error: null };
50
53
  return {
51
54
  data: [
52
55
  {
@@ -142,6 +145,7 @@ describe('InvoiceDetailsPage', () => {
142
145
  beforeEach(() => {
143
146
  vi.clearAllMocks();
144
147
  mocks.operations.length = 0;
148
+ mocks.omitPromotions = false;
145
149
  mocks.invoice = {
146
150
  created_at: '2026-05-01T00:00:00.000Z',
147
151
  customer_avatar_url: null,
@@ -209,6 +213,18 @@ describe('InvoiceDetailsPage', () => {
209
213
  );
210
214
  });
211
215
 
216
+ it('shows the stored discount after reopening even without promotion metadata', async () => {
217
+ mocks.omitPromotions = true;
218
+ render(
219
+ await InvoiceDetailsPage({
220
+ invoiceId: 'invoice-1',
221
+ locale: 'en',
222
+ wsId: 'workspace-1',
223
+ })
224
+ );
225
+ expect(screen.getByText('ws-invoices.discount')).toBeInTheDocument();
226
+ });
227
+
212
228
  it('does not load invoice children when the invoice is outside the workspace', async () => {
213
229
  mocks.invoice = null;
214
230
 
@@ -22,6 +22,7 @@ import 'dayjs/locale/vi';
22
22
  import moment from 'moment';
23
23
  import { notFound } from 'next/navigation';
24
24
  import { getTranslations } from 'next-intl/server';
25
+ import { getSavedInvoiceDiscount } from '../invoice-discount';
25
26
  import InvoiceCard from './invoice-card';
26
27
  import InvoiceEditForm from './invoice-edit-form';
27
28
 
@@ -50,6 +51,7 @@ export default async function InvoiceDetailsPage({
50
51
 
51
52
  const products = await getProducts(wsId, invoice.id);
52
53
  const promotions = await getPromotions(wsId, invoice.id);
54
+ const savedDiscount = getSavedInvoiceDiscount(products, invoice.price);
53
55
  const { data: configs } = await getConfigs(wsId);
54
56
 
55
57
  return (
@@ -206,6 +208,13 @@ export default async function InvoiceDetailsPage({
206
208
  />
207
209
  ))}
208
210
  </div>
211
+ ) : savedDiscount > 0 ? (
212
+ <div className="flex items-center justify-between gap-3 py-3">
213
+ <span>{t('ws-invoices.discount')}</span>
214
+ <FinanceDisplayAmount
215
+ value={formatCurrency(savedDiscount, currency)}
216
+ />
217
+ </div>
209
218
  ) : (
210
219
  <div className="py-8 text-center text-muted-foreground">
211
220
  <Percent className="mx-auto mb-3 h-12 w-12 opacity-50" />
@@ -32,6 +32,7 @@ type DeleteInvoiceAction = (
32
32
  interface Props {
33
33
  wsId: string;
34
34
  canDeleteInvoices?: boolean;
35
+ canRestoreInvoices?: boolean;
35
36
  canExport?: boolean;
36
37
  invoiceType?: 'created' | 'pending';
37
38
  deleteInvoiceAction?: DeleteInvoiceAction;
@@ -42,6 +43,7 @@ interface Props {
42
43
  export function InvoicesTable({
43
44
  wsId,
44
45
  canDeleteInvoices = false,
46
+ canRestoreInvoices = false,
45
47
  canExport = false,
46
48
  invoiceType = 'created',
47
49
  deleteInvoiceAction,
@@ -149,6 +151,7 @@ export function InvoicesTable({
149
151
 
150
152
  const extraData = {
151
153
  canDeleteInvoices,
154
+ canRestoreInvoices,
152
155
  deleteInvoiceAction,
153
156
  currency,
154
157
  };
@@ -100,10 +100,10 @@ export function PromotionForm({
100
100
  resolver: zodResolver(formSchema),
101
101
  defaultValues: {
102
102
  id: data?.id,
103
- name: data?.name,
104
- description: data?.description,
105
- code: data?.code,
106
- value: data?.value ? parseInt(data?.value.toString(), 10) : undefined,
103
+ name: data?.name ?? '',
104
+ description: data?.description ?? '',
105
+ code: data?.code ?? '',
106
+ value: data?.value != null ? Number(data.value) : undefined,
107
107
  unit: data?.use_ratio ? 'percentage' : 'currency',
108
108
  max_uses: data?.max_uses,
109
109
  },
@@ -222,6 +222,7 @@ export function PromotionForm({
222
222
  <FormControl>
223
223
  <Input
224
224
  type="number"
225
+ step="any"
225
226
  placeholder={t('ws-inventory-promotions.form.value')}
226
227
  onChange={(e) => field.onChange(e.target.valueAsNumber)}
227
228
  onBlur={field.onBlur}
@@ -11,6 +11,7 @@ const INVOICE_MUTATION_QUERY_ROOTS = new Set([
11
11
  'daily-chart',
12
12
  'infinite-user-invoices',
13
13
  'invoice-analytics',
14
+ 'invoice-history',
14
15
  'monthly-chart',
15
16
  'monthly-closing-balance',
16
17
  'monthly-opening-balance',
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
4
  import { Ellipsis, Eye } from '@tuturuuu/icons';
5
- import { deleteInvoice } from '@tuturuuu/internal-api/finance';
5
+ import { InternalApiError } from '@tuturuuu/internal-api';
6
+ import { deleteInvoice, restoreInvoice } from '@tuturuuu/internal-api/finance';
6
7
  import type { Invoice } from '@tuturuuu/types/primitives/Invoice';
7
8
  import {
8
9
  AlertDialog,
@@ -40,6 +41,7 @@ interface InvoiceRowActionsProps {
40
41
  row: Row<Invoice>;
41
42
  href?: string;
42
43
  canDeleteInvoices?: boolean;
44
+ canRestoreInvoices?: boolean;
43
45
  deleteInvoiceAction?: DeleteInvoiceAction;
44
46
  }
45
47
 
@@ -47,6 +49,7 @@ export function InvoiceRowActions({
47
49
  row,
48
50
  href,
49
51
  canDeleteInvoices = false,
52
+ canRestoreInvoices = false,
50
53
  deleteInvoiceAction,
51
54
  }: InvoiceRowActionsProps) {
52
55
  const t = useTranslations();
@@ -74,13 +77,41 @@ export function InvoiceRowActions({
74
77
 
75
78
  if (result.success) {
76
79
  await invalidateInvoiceMutationQueries(queryClient, data.ws_id);
77
- toast.success(t('ws-invoices.invoice_deleted'));
80
+ toast.success(t('ws-invoices.invoice_deleted'), {
81
+ action:
82
+ deleteInvoiceAction || !canRestoreInvoices
83
+ ? undefined
84
+ : {
85
+ label: t('ws-invoices.recovery_undo'),
86
+ onClick: async () => {
87
+ try {
88
+ await restoreInvoice(data.ws_id!, data.id!);
89
+ await invalidateInvoiceMutationQueries(
90
+ queryClient,
91
+ data.ws_id!
92
+ );
93
+ toast.success(t('ws-invoices.recovery_success'));
94
+ router.refresh();
95
+ } catch {
96
+ toast.error(t('ws-invoices.recovery_error'));
97
+ }
98
+ },
99
+ },
100
+ });
78
101
  router.refresh();
79
102
  } else {
80
103
  toast.error(result.message || t('ws-invoices.failed_delete_invoice'));
81
104
  }
82
- } catch {
83
- toast.error(t('ws-invoices.failed_delete_invoice'));
105
+ } catch (error) {
106
+ toast.error(
107
+ t(
108
+ error instanceof InternalApiError && error.status === 409
109
+ ? 'ws-invoices.delete_linked_records'
110
+ : error instanceof InternalApiError && error.status === 503
111
+ ? 'ws-invoices.delete_unavailable'
112
+ : 'ws-invoices.failed_delete_invoice'
113
+ )
114
+ );
84
115
  } finally {
85
116
  setIsDeleting(false);
86
117
  }
@@ -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 { data: userGroups = [], isLoading: userGroupsLoading } = useUserGroups(
151
- wsId,
152
- selectedUserId
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 (isLoadingData) {
451
+ if (
452
+ isLoadingData ||
453
+ usersError ||
454
+ (canReadInvoiceProducts &&
455
+ productsError &&
456
+ !isPermissionRequestError(productsError)) ||
457
+ userGroupsError
458
+ ) {
447
459
  return (
448
- <div className="flex items-center justify-center py-8">
449
- <div className="flex items-center gap-2">
450
- <Loader2 className="h-4 w-4 animate-spin" />
451
- <p className="text-muted-foreground text-sm">
452
- {t('ws-invoices.loading')}
453
- </p>
454
- </div>
455
- </div>
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}