@tuturuuu/ui 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/biome.json +1 -1
- package/package.json +53 -51
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
- package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
- package/src/components/ui/chat/ai-message-parts.tsx +23 -16
- package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
- package/src/components/ui/commercial-comparison-style.ts +104 -0
- package/src/components/ui/commercial-comparison-table.tsx +159 -0
- package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
- package/src/components/ui/commercial-comparison.test.tsx +86 -0
- package/src/components/ui/commercial-comparison.tsx +224 -0
- package/src/components/ui/comparison-value.test.tsx +32 -0
- package/src/components/ui/comparison-value.tsx +72 -0
- package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
- package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
- package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
- package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
- package/src/components/ui/custom/report-preview-pagination.ts +5 -3
- package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
- package/src/components/ui/custom/report-preview.tsx +2 -3
- package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
- package/src/components/ui/finance/invoices/columns.tsx +2 -0
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
- package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
- package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- package/src/components/ui/finance/invoices/hooks.ts +5 -110
- package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
- package/src/components/ui/finance/invoices/internal-api.ts +19 -1
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
- package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
- package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
- package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
- package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
- package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
- package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
- package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
- package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
- package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
- package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
- package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
- package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
- package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- package/src/components/ui/legacy/calendar/working-location.ts +35 -0
- package/src/components/ui/public-workspace-prices.tsx +13 -0
- package/src/components/ui/pwa-status.test.tsx +13 -2
- package/src/components/ui/pwa-status.tsx +15 -3
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
- package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
- package/src/hooks/calendar-provider-sync.ts +33 -0
- package/src/hooks/notification-subscription.ts +74 -0
- package/src/hooks/use-calendar-sync.tsx +11 -14
- package/src/hooks/use-calendar.tsx +2 -2
- package/src/hooks/use-notifications.ts +5 -141
|
@@ -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
|
-
<
|
|
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>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { SubscriptionPaymentPeriod } from '@tuturuuu/internal-api/finance-subscription-analytics';
|
|
4
|
+
import { useLocale, useTranslations } from 'next-intl';
|
|
5
|
+
import {
|
|
6
|
+
Bar,
|
|
7
|
+
BarChart,
|
|
8
|
+
CartesianGrid,
|
|
9
|
+
ResponsiveContainer,
|
|
10
|
+
Tooltip,
|
|
11
|
+
XAxis,
|
|
12
|
+
YAxis,
|
|
13
|
+
} from 'recharts';
|
|
14
|
+
import { FINANCE_HIDDEN_AMOUNT } from '../../shared/use-finance-confidential-visibility';
|
|
15
|
+
|
|
16
|
+
export type PaymentMetric =
|
|
17
|
+
| 'amount'
|
|
18
|
+
| 'paidUsers'
|
|
19
|
+
| 'paidGroups'
|
|
20
|
+
| 'memberships';
|
|
21
|
+
export function SubscriptionPaymentChart({
|
|
22
|
+
periods,
|
|
23
|
+
metric,
|
|
24
|
+
currency,
|
|
25
|
+
hidden,
|
|
26
|
+
}: {
|
|
27
|
+
periods: SubscriptionPaymentPeriod[];
|
|
28
|
+
metric: PaymentMetric;
|
|
29
|
+
currency: string;
|
|
30
|
+
hidden: boolean;
|
|
31
|
+
}) {
|
|
32
|
+
const t = useTranslations('invoice-subscriptions');
|
|
33
|
+
const locale = useLocale();
|
|
34
|
+
const number = new Intl.NumberFormat(locale);
|
|
35
|
+
const money = new Intl.NumberFormat(locale, { style: 'currency', currency });
|
|
36
|
+
const label = (value: string) =>
|
|
37
|
+
value.length === 4
|
|
38
|
+
? value
|
|
39
|
+
: new Intl.DateTimeFormat(locale, {
|
|
40
|
+
month: 'short',
|
|
41
|
+
year: 'numeric',
|
|
42
|
+
timeZone: 'UTC',
|
|
43
|
+
}).format(new Date(`${value}-01T00:00:00Z`));
|
|
44
|
+
const format = (value: number, key: string) =>
|
|
45
|
+
key === 'amount'
|
|
46
|
+
? hidden
|
|
47
|
+
? FINANCE_HIDDEN_AMOUNT
|
|
48
|
+
: money.format(value)
|
|
49
|
+
: number.format(value);
|
|
50
|
+
if (hidden && metric === 'amount')
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex h-64 items-center justify-center rounded-lg bg-muted/40 text-muted-foreground text-sm">
|
|
53
|
+
{t('hidden')}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className="h-72 w-full min-w-0"
|
|
59
|
+
role="img"
|
|
60
|
+
aria-label={t('chart_label', { metric: t(metric) })}
|
|
61
|
+
>
|
|
62
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
63
|
+
<BarChart
|
|
64
|
+
data={periods}
|
|
65
|
+
accessibilityLayer
|
|
66
|
+
margin={{ top: 16, right: 12, left: 12, bottom: 0 }}
|
|
67
|
+
>
|
|
68
|
+
<CartesianGrid
|
|
69
|
+
vertical={false}
|
|
70
|
+
stroke="var(--border)"
|
|
71
|
+
strokeDasharray="3 3"
|
|
72
|
+
/>
|
|
73
|
+
<XAxis
|
|
74
|
+
dataKey="period"
|
|
75
|
+
tickFormatter={label}
|
|
76
|
+
tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
|
|
77
|
+
tickLine={false}
|
|
78
|
+
axisLine={false}
|
|
79
|
+
minTickGap={24}
|
|
80
|
+
/>
|
|
81
|
+
<YAxis
|
|
82
|
+
tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
|
|
83
|
+
tickLine={false}
|
|
84
|
+
axisLine={false}
|
|
85
|
+
allowDecimals={metric === 'amount'}
|
|
86
|
+
width={66}
|
|
87
|
+
tickFormatter={(value) =>
|
|
88
|
+
new Intl.NumberFormat(locale, { notation: 'compact' }).format(
|
|
89
|
+
value
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
<Tooltip
|
|
94
|
+
cursor={{ fill: 'var(--muted)', opacity: 0.5 }}
|
|
95
|
+
content={({ active, payload }) => {
|
|
96
|
+
const row = payload?.[0]?.payload as
|
|
97
|
+
| SubscriptionPaymentPeriod
|
|
98
|
+
| undefined;
|
|
99
|
+
if (!active || !row) return null;
|
|
100
|
+
return (
|
|
101
|
+
<div className="min-w-52 rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg">
|
|
102
|
+
<p className="mb-3 font-semibold">{label(row.period)}</p>
|
|
103
|
+
{(
|
|
104
|
+
[
|
|
105
|
+
'amount',
|
|
106
|
+
'paidUsers',
|
|
107
|
+
'paidGroups',
|
|
108
|
+
'memberships',
|
|
109
|
+
] as const
|
|
110
|
+
).map((key) => (
|
|
111
|
+
<div
|
|
112
|
+
key={key}
|
|
113
|
+
className="flex justify-between gap-6 py-1 text-sm"
|
|
114
|
+
>
|
|
115
|
+
<span className="text-muted-foreground">{t(key)}</span>
|
|
116
|
+
<span className="font-medium tabular-nums">
|
|
117
|
+
{format(row[key], key)}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
<Bar
|
|
126
|
+
dataKey={metric}
|
|
127
|
+
name={t(metric)}
|
|
128
|
+
fill="var(--primary)"
|
|
129
|
+
radius={[5, 5, 0, 0]}
|
|
130
|
+
maxBarSize={48}
|
|
131
|
+
isAnimationActive={false}
|
|
132
|
+
/>
|
|
133
|
+
</BarChart>
|
|
134
|
+
</ResponsiveContainer>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { SubscriptionPaymentCurrency } from '@tuturuuu/internal-api/finance-subscription-analytics';
|
|
4
|
+
import { useLocale, useTranslations } from 'next-intl';
|
|
5
|
+
import {
|
|
6
|
+
Tooltip,
|
|
7
|
+
TooltipContent,
|
|
8
|
+
TooltipProvider,
|
|
9
|
+
TooltipTrigger,
|
|
10
|
+
} from '../../../tooltip';
|
|
11
|
+
import { FINANCE_HIDDEN_AMOUNT } from '../../shared/use-finance-confidential-visibility';
|
|
12
|
+
|
|
13
|
+
export function SubscriptionPaymentDetails({
|
|
14
|
+
data,
|
|
15
|
+
hidden,
|
|
16
|
+
}: {
|
|
17
|
+
data: SubscriptionPaymentCurrency;
|
|
18
|
+
hidden: boolean;
|
|
19
|
+
}) {
|
|
20
|
+
const t = useTranslations('invoice-subscriptions');
|
|
21
|
+
const locale = useLocale();
|
|
22
|
+
const format = (value: number) =>
|
|
23
|
+
hidden
|
|
24
|
+
? FINANCE_HIDDEN_AMOUNT
|
|
25
|
+
: new Intl.NumberFormat(locale, {
|
|
26
|
+
style: 'currency',
|
|
27
|
+
currency: data.currency,
|
|
28
|
+
}).format(value);
|
|
29
|
+
const max = Math.max(1, ...data.distribution.map((bin) => bin.userCount));
|
|
30
|
+
return (
|
|
31
|
+
<div className="space-y-5">
|
|
32
|
+
<div className="rounded-xl border bg-muted/20 p-4">
|
|
33
|
+
<h3 className="font-medium text-sm">{t('distribution')}</h3>
|
|
34
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
35
|
+
{t('distribution_help')}
|
|
36
|
+
</p>
|
|
37
|
+
<TooltipProvider>
|
|
38
|
+
<div className="mt-4 flex flex-wrap gap-3">
|
|
39
|
+
{data.distribution.map((bin) => (
|
|
40
|
+
<Tooltip key={bin.groupCount}>
|
|
41
|
+
<TooltipTrigger asChild>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
className="min-w-36 flex-1 rounded-lg border bg-background p-3 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
45
|
+
aria-label={t('distribution_value', bin)}
|
|
46
|
+
>
|
|
47
|
+
<div className="flex justify-between gap-4 text-sm">
|
|
48
|
+
<span>
|
|
49
|
+
{t('groups_count', { count: bin.groupCount })}
|
|
50
|
+
</span>
|
|
51
|
+
<strong className="tabular-nums">{bin.userCount}</strong>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
|
54
|
+
<div
|
|
55
|
+
className="h-full rounded-full bg-primary"
|
|
56
|
+
style={{ width: `${(bin.userCount / max) * 100}%` }}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
</button>
|
|
60
|
+
</TooltipTrigger>
|
|
61
|
+
<TooltipContent>{t('distribution_value', bin)}</TooltipContent>
|
|
62
|
+
</Tooltip>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</TooltipProvider>
|
|
66
|
+
</div>
|
|
67
|
+
<details className="rounded-xl border p-4">
|
|
68
|
+
<summary className="cursor-pointer font-medium text-sm focus-visible:outline-ring">
|
|
69
|
+
{t('table')}
|
|
70
|
+
</summary>
|
|
71
|
+
<div className="mt-4 overflow-x-auto">
|
|
72
|
+
<table className="w-full text-sm tabular-nums">
|
|
73
|
+
<caption className="sr-only">{t('title')}</caption>
|
|
74
|
+
<thead>
|
|
75
|
+
<tr className="border-b text-muted-foreground">
|
|
76
|
+
{[
|
|
77
|
+
'period',
|
|
78
|
+
'paidUsers',
|
|
79
|
+
'paidGroups',
|
|
80
|
+
'memberships',
|
|
81
|
+
'amount',
|
|
82
|
+
].map((key) => (
|
|
83
|
+
<th
|
|
84
|
+
key={key}
|
|
85
|
+
scope="col"
|
|
86
|
+
className="px-3 py-2 text-start font-medium"
|
|
87
|
+
>
|
|
88
|
+
{t(key)}
|
|
89
|
+
</th>
|
|
90
|
+
))}
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
{data.periods.map((row) => (
|
|
95
|
+
<tr key={row.period} className="border-b last:border-0">
|
|
96
|
+
<th scope="row" className="px-3 py-2 text-start font-medium">
|
|
97
|
+
{row.period}
|
|
98
|
+
</th>
|
|
99
|
+
<td className="px-3 py-2">{row.paidUsers}</td>
|
|
100
|
+
<td className="px-3 py-2">{row.paidGroups}</td>
|
|
101
|
+
<td className="px-3 py-2">{row.memberships}</td>
|
|
102
|
+
<td className="px-3 py-2">{format(row.amount)}</td>
|
|
103
|
+
</tr>
|
|
104
|
+
))}
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
</div>
|
|
108
|
+
</details>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -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
|
-
{
|
|
56
|
+
{displayedDiscount > 0 && (
|
|
55
57
|
<div className="flex justify-between">
|
|
56
58
|
<span className="text-muted-foreground">
|
|
57
|
-
{t('ws-invoices.discount')}
|
|
59
|
+
{t('ws-invoices.discount')}
|
|
60
|
+
{discountLabel ? ` (${discountLabel})` : ''}
|
|
58
61
|
</span>
|
|
59
62
|
<FinanceDisplayAmount
|
|
60
63
|
alwaysShow
|
|
61
64
|
className={discountClassName}
|
|
62
|
-
value={`-${formatCurrency(
|
|
65
|
+
value={`-${formatCurrency(displayedDiscount, currency)}`}
|
|
63
66
|
/>
|
|
64
67
|
</div>
|
|
65
68
|
)}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { InvoiceDataState } from './invoice-data-state';
|
|
4
|
+
|
|
5
|
+
vi.mock('next-intl', () => ({
|
|
6
|
+
useTranslations: () => (key: string) => key,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('InvoiceDataState', () => {
|
|
10
|
+
it('announces loading without offering duplicate requests', () => {
|
|
11
|
+
render(<InvoiceDataState loading onRetry={vi.fn()} />);
|
|
12
|
+
expect(screen.getByRole('status')).toHaveTextContent('ws-invoices.loading');
|
|
13
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('replaces loading with an actionable retry after failure', () => {
|
|
17
|
+
const onRetry = vi.fn();
|
|
18
|
+
const { rerender } = render(<InvoiceDataState loading onRetry={onRetry} />);
|
|
19
|
+
rerender(<InvoiceDataState loading={false} onRetry={onRetry} />);
|
|
20
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
21
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
22
|
+
'ws-invoices.load_failed'
|
|
23
|
+
);
|
|
24
|
+
fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
|
|
25
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from '@tuturuuu/icons';
|
|
4
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
6
|
+
|
|
7
|
+
export function InvoiceDataState({
|
|
8
|
+
loading,
|
|
9
|
+
onRetry,
|
|
10
|
+
}: {
|
|
11
|
+
loading: boolean;
|
|
12
|
+
onRetry: () => void;
|
|
13
|
+
}) {
|
|
14
|
+
const t = useTranslations();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
|
18
|
+
<div
|
|
19
|
+
role={loading ? 'status' : 'alert'}
|
|
20
|
+
className="flex items-center gap-2"
|
|
21
|
+
>
|
|
22
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
23
|
+
<p className="text-muted-foreground text-sm">
|
|
24
|
+
{t(loading ? 'ws-invoices.loading' : 'ws-invoices.load_failed')}
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
{!loading && (
|
|
28
|
+
<Button type="button" variant="outline" onClick={onRetry}>
|
|
29
|
+
{t('common.retry')}
|
|
30
|
+
</Button>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -35,6 +35,7 @@ type LatestInvoice = {
|
|
|
35
35
|
group_id?: string;
|
|
36
36
|
valid_until?: string | null;
|
|
37
37
|
created_at?: string | null;
|
|
38
|
+
covered_months?: string[] | null;
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
type UserGroupItem = {
|
|
@@ -77,6 +78,7 @@ function GroupRow({
|
|
|
77
78
|
isSelected,
|
|
78
79
|
isMonthPaid,
|
|
79
80
|
latestInvoice,
|
|
81
|
+
coverageInvoices,
|
|
80
82
|
locale,
|
|
81
83
|
isLoadingSubscriptionData,
|
|
82
84
|
onToggle,
|
|
@@ -86,12 +88,17 @@ function GroupRow({
|
|
|
86
88
|
isSelected: boolean;
|
|
87
89
|
isMonthPaid: boolean;
|
|
88
90
|
latestInvoice: LatestInvoice | undefined;
|
|
91
|
+
coverageInvoices: LatestInvoice[];
|
|
89
92
|
locale: string;
|
|
90
93
|
isLoadingSubscriptionData: boolean;
|
|
91
94
|
onToggle: () => void;
|
|
92
95
|
t: (key: string) => string;
|
|
93
96
|
}) {
|
|
94
|
-
const paymentStatus = getGroupPaymentStatus(
|
|
97
|
+
const paymentStatus = getGroupPaymentStatus(
|
|
98
|
+
group,
|
|
99
|
+
latestInvoice,
|
|
100
|
+
coverageInvoices
|
|
101
|
+
);
|
|
95
102
|
return (
|
|
96
103
|
<button
|
|
97
104
|
type="button"
|
|
@@ -151,13 +158,23 @@ function GroupRow({
|
|
|
151
158
|
{new Date(latestInvoice.created_at).toLocaleDateString(locale)}
|
|
152
159
|
</span>
|
|
153
160
|
)}
|
|
154
|
-
{latestInvoice?.
|
|
161
|
+
{latestInvoice?.covered_months != null ? (
|
|
155
162
|
<span>
|
|
156
|
-
{t('
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
{t('invoice-subscriptions.coverage_months')}:{' '}
|
|
164
|
+
{latestInvoice.covered_months
|
|
165
|
+
.map((month) => month.slice(0, 7))
|
|
166
|
+
.sort()
|
|
167
|
+
.join(', ')}
|
|
160
168
|
</span>
|
|
169
|
+
) : (
|
|
170
|
+
latestInvoice?.valid_until && (
|
|
171
|
+
<span>
|
|
172
|
+
{t('ws-invoices.valid_until')}{' '}
|
|
173
|
+
{parseLocalCalendarDate(
|
|
174
|
+
latestInvoice.valid_until
|
|
175
|
+
).toLocaleDateString(locale)}
|
|
176
|
+
</span>
|
|
177
|
+
)
|
|
161
178
|
)}
|
|
162
179
|
</p>
|
|
163
180
|
)}
|
|
@@ -270,6 +287,9 @@ export function SubscriptionGroupSelector({
|
|
|
270
287
|
isSelected={isSelected}
|
|
271
288
|
isMonthPaid={isMonthPaid}
|
|
272
289
|
latestInvoice={latestInvoice}
|
|
290
|
+
coverageInvoices={latestSubscriptionInvoices.filter(
|
|
291
|
+
(invoice) => invoice.group_id === group.id
|
|
292
|
+
)}
|
|
273
293
|
locale={locale}
|
|
274
294
|
isLoadingSubscriptionData={isLoadingSubscriptionData}
|
|
275
295
|
onToggle={() => onGroupSelect(group.id)}
|
|
@@ -317,6 +337,9 @@ export function SubscriptionGroupSelector({
|
|
|
317
337
|
isSelected={isSelected}
|
|
318
338
|
isMonthPaid={isMonthPaid}
|
|
319
339
|
latestInvoice={latestInvoice}
|
|
340
|
+
coverageInvoices={latestSubscriptionInvoices.filter(
|
|
341
|
+
(invoice) => invoice.group_id === group.id
|
|
342
|
+
)}
|
|
320
343
|
locale={locale}
|
|
321
344
|
isLoadingSubscriptionData={isLoadingSubscriptionData}
|
|
322
345
|
onToggle={() => onGroupSelect(group.id)}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { XLSX } from '../../../../xlsx';
|
|
3
|
+
import { getData, getPendingInvoicesData } from './export-data';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({ pending: vi.fn(), created: vi.fn() }));
|
|
6
|
+
vi.mock('@tuturuuu/internal-api/finance', () => ({
|
|
7
|
+
listPendingFinanceInvoices: mocks.pending,
|
|
8
|
+
listFinanceInvoices: mocks.created,
|
|
9
|
+
}));
|
|
10
|
+
beforeEach(() => vi.clearAllMocks());
|
|
11
|
+
|
|
12
|
+
describe('invoice export normalization', () => {
|
|
13
|
+
it('round-trips Vietnamese class names and all debt rows through a real Excel workbook', async () => {
|
|
14
|
+
const data = [
|
|
15
|
+
{
|
|
16
|
+
user_id: 'student-1',
|
|
17
|
+
user_name: 'Nguyễn An',
|
|
18
|
+
group_id: 'class-1',
|
|
19
|
+
group_name: 'Lớp cô Ngân',
|
|
20
|
+
months_owed: ['2026-08', '2026-09'],
|
|
21
|
+
potential_total: 1200000,
|
|
22
|
+
total_sessions: 8,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
user_id: 'student-1',
|
|
26
|
+
user_name: 'Nguyễn An',
|
|
27
|
+
group_id: 'class-2',
|
|
28
|
+
group_name: 'Lớp thầy Bình',
|
|
29
|
+
months_owed: '2026-09',
|
|
30
|
+
potential_total: 800000,
|
|
31
|
+
total_sessions: 4,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
mocks.pending.mockResolvedValue({ count: 2, data });
|
|
35
|
+
const result = await getPendingInvoicesData('ws', {});
|
|
36
|
+
const flat = result.data.map(
|
|
37
|
+
({ customer: _customer, creator: _creator, wallet: _wallet, ...row }) =>
|
|
38
|
+
row
|
|
39
|
+
);
|
|
40
|
+
const workbook = XLSX.utils.book_new();
|
|
41
|
+
XLSX.utils.book_append_sheet(
|
|
42
|
+
workbook,
|
|
43
|
+
XLSX.utils.json_to_sheet(flat),
|
|
44
|
+
'Debt'
|
|
45
|
+
);
|
|
46
|
+
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
|
|
47
|
+
const parsed = XLSX.read(bytes, { type: 'array' });
|
|
48
|
+
const rows = XLSX.utils.sheet_to_json(parsed.Sheets.Debt!);
|
|
49
|
+
expect(rows).toHaveLength(2);
|
|
50
|
+
expect(rows).toEqual(
|
|
51
|
+
expect.arrayContaining([
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
user_name: 'Nguyễn An',
|
|
54
|
+
group_name: 'Lớp cô Ngân',
|
|
55
|
+
months_owed: '2026-08, 2026-09',
|
|
56
|
+
potential_total: 1200000,
|
|
57
|
+
}),
|
|
58
|
+
expect.objectContaining({
|
|
59
|
+
user_name: 'Nguyễn An',
|
|
60
|
+
group_name: 'Lớp thầy Bình',
|
|
61
|
+
months_owed: '2026-09',
|
|
62
|
+
potential_total: 800000,
|
|
63
|
+
}),
|
|
64
|
+
])
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('preserves grouped classes and scalar user filters', async () => {
|
|
69
|
+
mocks.pending.mockResolvedValue({
|
|
70
|
+
count: 1,
|
|
71
|
+
data: [
|
|
72
|
+
{
|
|
73
|
+
user_id: 'student',
|
|
74
|
+
group_ids: ['a', 'b'],
|
|
75
|
+
group_names: ['A', 'B'],
|
|
76
|
+
months_owed: ['2026-09'],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const result = await getPendingInvoicesData('ws', {
|
|
81
|
+
groupByUser: true,
|
|
82
|
+
userIds: 'student',
|
|
83
|
+
q: 'A',
|
|
84
|
+
});
|
|
85
|
+
expect(result.data[0]).toMatchObject({
|
|
86
|
+
group_id: 'a,b',
|
|
87
|
+
group_name: 'A, B',
|
|
88
|
+
months_owed: '2026-09',
|
|
89
|
+
});
|
|
90
|
+
expect(mocks.pending).toHaveBeenCalledWith(
|
|
91
|
+
'ws',
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
userIds: ['student'],
|
|
94
|
+
q: 'A',
|
|
95
|
+
groupByUser: true,
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('preserves both legacy and multiple-wallet filters in created exports', async () => {
|
|
101
|
+
mocks.created.mockResolvedValue({ count: 0, data: [] });
|
|
102
|
+
await getData('ws', {
|
|
103
|
+
walletId: 'cash',
|
|
104
|
+
walletIds: ['bank'],
|
|
105
|
+
userIds: 'student',
|
|
106
|
+
start: '2026-09-01',
|
|
107
|
+
end: '2026-09-30',
|
|
108
|
+
q: 'tuition',
|
|
109
|
+
});
|
|
110
|
+
expect(mocks.created).toHaveBeenCalledWith(
|
|
111
|
+
'ws',
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
walletIds: ['cash', 'bank'],
|
|
114
|
+
userIds: ['student'],
|
|
115
|
+
start: '2026-09-01',
|
|
116
|
+
end: '2026-09-30',
|
|
117
|
+
q: 'tuition',
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|