@tuturuuu/ui 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. package/src/hooks/use-notifications.ts +5 -141
@@ -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>
@@ -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
- {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
  )}
@@ -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(group, latestInvoice);
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?.valid_until && (
161
+ {latestInvoice?.covered_months != null ? (
155
162
  <span>
156
- {t('ws-invoices.valid_until')}{' '}
157
- {parseLocalCalendarDate(
158
- latestInvoice.valid_until
159
- ).toLocaleDateString(locale)}
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
+ });