@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.
- package/CHANGELOG.md +57 -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-markdown.tsx +17 -2
- 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/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/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/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.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-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.ts +1 -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-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 +44 -45
- 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/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
|
@@ -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 =
|
|
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
|
-
{
|
|
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 ?
|
|
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}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useQueryClient } from '@tanstack/react-query';
|
|
4
4
|
import { Ellipsis, Eye } from '@tuturuuu/icons';
|
|
5
|
-
import {
|
|
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(
|
|
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 {
|
|
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}
|