@tuturuuu/ui 0.10.0 → 0.11.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 (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -4
  3. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  4. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  5. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  6. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  7. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  8. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  9. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  10. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  11. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  12. package/src/components/ui/finance/invoices/utils.ts +240 -7
  13. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -0
  14. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
  15. package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
  16. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
  17. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  18. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  19. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  20. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  21. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  22. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  23. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  24. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  25. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  26. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  27. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  28. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
  29. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  30. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  31. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  32. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  33. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  34. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  35. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  36. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/tutur3u/platform/compare/ui-v0.10.0...ui-v0.11.0) (2026-06-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **finance:** support prepaid subscription invoice ranges ([fa0f338](https://github.com/tutur3u/platform/commit/fa0f3388dfb3d3ea539a8d8bdc3f100f268542c6))
9
+ * **tasks:** add board header loading skeleton ([73f1242](https://github.com/tutur3u/platform/commit/73f12421a9cd50c035a1c9940e152354b1974f26))
10
+ * **tasks:** add task templates ([8d0700a](https://github.com/tutur3u/platform/commit/8d0700ad255c7b5874bfa065575df6b1cde34063))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **tasks:** pad board header skeleton ([b0c5c0f](https://github.com/tutur3u/platform/commit/b0c5c0fea8871cac2603da6239b980c752b1c9f6))
16
+ * **tasks:** prevent description wipes during yjs hydration ([947f38c](https://github.com/tutur3u/platform/commit/947f38c6474d07b1fac1c44e166638bfc87260c0))
17
+ * **tasks:** restore tracked task descriptions ([f892ae2](https://github.com/tutur3u/platform/commit/f892ae23dfec41c2d25649b97a628d8cdcd1fa5d))
18
+
3
19
  ## [0.10.0](https://github.com/tutur3u/platform/compare/ui-v0.9.0...ui-v0.10.0) (2026-06-26)
4
20
 
5
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -88,9 +88,9 @@
88
88
  "@tuturuuu/apis": "0.7.0",
89
89
  "@tuturuuu/hooks": "0.0.2",
90
90
  "@tuturuuu/icons": "0.0.6",
91
- "@tuturuuu/internal-api": "0.11.0",
91
+ "@tuturuuu/internal-api": "0.12.0",
92
92
  "@tuturuuu/supabase": "0.4.0",
93
- "@tuturuuu/utils": "0.10.0",
93
+ "@tuturuuu/utils": "0.11.0",
94
94
  "@types/debug": "^4.1.13",
95
95
  "browser-image-compression": "^2.0.2",
96
96
  "class-variance-authority": "^0.7.1",
@@ -149,7 +149,7 @@
149
149
  "@tanstack/react-table": "^8.21.3",
150
150
  "@testing-library/jest-dom": "^6.9.1",
151
151
  "@testing-library/react": "^16.3.2",
152
- "@tuturuuu/types": "0.12.0",
152
+ "@tuturuuu/types": "0.13.0",
153
153
  "@tuturuuu/typescript-config": "0.1.1",
154
154
  "@types/html2canvas": "^1.0.0",
155
155
  "@types/lodash": "^4.17.24",
@@ -54,6 +54,8 @@ interface SubscriptionAttendanceSummaryProps {
54
54
  };
55
55
  totalSessions: number;
56
56
  attendanceRate: number;
57
+ coverageRangeLabel: string;
58
+ prepaidMonthCount: number;
57
59
  }
58
60
 
59
61
  export function SubscriptionAttendanceSummary({
@@ -73,6 +75,8 @@ export function SubscriptionAttendanceSummary({
73
75
  attendanceStats,
74
76
  totalSessions,
75
77
  attendanceRate,
78
+ coverageRangeLabel,
79
+ prepaidMonthCount,
76
80
  }: SubscriptionAttendanceSummaryProps): React.ReactElement {
77
81
  const t = useTranslations();
78
82
 
@@ -82,6 +86,8 @@ export function SubscriptionAttendanceSummary({
82
86
  ? selectedMonth
83
87
  : (availableMonths[0]?.value ?? selectedMonth);
84
88
  const monthLabel = formatMonthLabel(resolvedSelectedMonth, locale);
89
+ const displayedRangeLabel =
90
+ prepaidMonthCount > 1 ? coverageRangeLabel : monthLabel;
85
91
 
86
92
  return (
87
93
  <Card>
@@ -98,17 +104,25 @@ export function SubscriptionAttendanceSummary({
98
104
  <CardDescription className="flex flex-col gap-1">
99
105
  {selectedGroupIds.length === 1
100
106
  ? t('ws-invoices.attendance_for_month', {
101
- month: monthLabel,
107
+ month: displayedRangeLabel,
102
108
  })
103
109
  : t('ws-invoices.combined_attendance_for_month', {
104
110
  count: selectedGroupIds.length,
105
- month: monthLabel,
106
- default: `Combined attendance from ${selectedGroupIds.length} groups for ${monthLabel}`,
111
+ month: displayedRangeLabel,
112
+ default: `Combined attendance from ${selectedGroupIds.length} groups for ${displayedRangeLabel}`,
107
113
  })}
108
114
  <span className="text-muted-foreground text-xs">
109
115
  {isSelectedMonthPaid
110
- ? t('ws-invoices.all_groups_paid_for_month')
111
- : t('ws-invoices.some_groups_unpaid_for_month')}
116
+ ? t(
117
+ prepaidMonthCount > 1
118
+ ? 'ws-invoices.all_groups_paid_for_range'
119
+ : 'ws-invoices.all_groups_paid_for_month'
120
+ )
121
+ : t(
122
+ prepaidMonthCount > 1
123
+ ? 'ws-invoices.some_groups_unpaid_for_range'
124
+ : 'ws-invoices.some_groups_unpaid_for_month'
125
+ )}
112
126
  </span>
113
127
  </CardDescription>
114
128
  </div>
@@ -0,0 +1,41 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { SubscriptionPrepaidControls } from './subscription-prepaid-controls';
4
+
5
+ vi.mock('next-intl', () => ({
6
+ useTranslations: () => (key: string, values?: Record<string, unknown>) => {
7
+ const messages: Record<string, string> = {
8
+ 'ws-invoices.coverage_range': 'Coverage',
9
+ 'ws-invoices.prepaid_month_count': 'Months to cover',
10
+ 'ws-invoices.prepaid_month_count_many': '{count} months',
11
+ 'ws-invoices.prepaid_month_count_one': '1 month',
12
+ 'ws-invoices.prepaid_months': 'Prepaid months',
13
+ 'ws-invoices.prepaid_months_description':
14
+ 'Choose how many months this subscription invoice should cover.',
15
+ 'ws-invoices.valid_until': 'Valid until',
16
+ };
17
+
18
+ return (messages[key] ?? key).replace(
19
+ '{count}',
20
+ String(values?.count ?? '')
21
+ );
22
+ },
23
+ }));
24
+
25
+ describe('SubscriptionPrepaidControls', () => {
26
+ it('renders the prepaid count selector and coverage summary', () => {
27
+ render(
28
+ <SubscriptionPrepaidControls
29
+ coverageRangeLabel="June 2026 - August 2026"
30
+ prepaidMonthCount={3}
31
+ validUntilLabel="September 2026"
32
+ onPrepaidMonthCountChange={vi.fn()}
33
+ />
34
+ );
35
+
36
+ expect(screen.getByText('Prepaid months')).toBeInTheDocument();
37
+ expect(screen.getByText('Months to cover')).toBeInTheDocument();
38
+ expect(screen.getByText('June 2026 - August 2026')).toBeInTheDocument();
39
+ expect(screen.getByText('September 2026')).toBeInTheDocument();
40
+ });
41
+ });
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from '@tuturuuu/ui/card';
10
+ import { Label } from '@tuturuuu/ui/label';
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from '@tuturuuu/ui/select';
18
+ import { useTranslations } from 'next-intl';
19
+ import type React from 'react';
20
+ import { MAX_PREPAID_MONTH_COUNT } from '../utils';
21
+
22
+ interface SubscriptionPrepaidControlsProps {
23
+ coverageRangeLabel: string;
24
+ onPrepaidMonthCountChange: (monthCount: number) => void;
25
+ prepaidMonthCount: number;
26
+ validUntilLabel: string;
27
+ }
28
+
29
+ const PREPAID_MONTH_OPTIONS = Array.from(
30
+ { length: MAX_PREPAID_MONTH_COUNT },
31
+ (_, index) => index + 1
32
+ );
33
+
34
+ export function SubscriptionPrepaidControls({
35
+ coverageRangeLabel,
36
+ onPrepaidMonthCountChange,
37
+ prepaidMonthCount,
38
+ validUntilLabel,
39
+ }: SubscriptionPrepaidControlsProps): React.ReactElement {
40
+ const t = useTranslations();
41
+
42
+ return (
43
+ <Card>
44
+ <CardHeader>
45
+ <CardTitle>{t('ws-invoices.prepaid_months')}</CardTitle>
46
+ <CardDescription>
47
+ {t('ws-invoices.prepaid_months_description')}
48
+ </CardDescription>
49
+ </CardHeader>
50
+ <CardContent className="space-y-4">
51
+ <div className="space-y-2">
52
+ <Label htmlFor="prepaid-month-count">
53
+ {t('ws-invoices.prepaid_month_count')}
54
+ </Label>
55
+ <Select
56
+ value={String(prepaidMonthCount)}
57
+ onValueChange={(value) => onPrepaidMonthCountChange(Number(value))}
58
+ >
59
+ <SelectTrigger id="prepaid-month-count">
60
+ <SelectValue />
61
+ </SelectTrigger>
62
+ <SelectContent>
63
+ {PREPAID_MONTH_OPTIONS.map((monthCount) => (
64
+ <SelectItem key={monthCount} value={String(monthCount)}>
65
+ {monthCount === 1
66
+ ? t('ws-invoices.prepaid_month_count_one')
67
+ : t('ws-invoices.prepaid_month_count_many', {
68
+ count: monthCount,
69
+ })}
70
+ </SelectItem>
71
+ ))}
72
+ </SelectContent>
73
+ </Select>
74
+ </div>
75
+
76
+ <div className="grid gap-3 rounded-lg border bg-muted/30 p-3 text-sm">
77
+ <div className="flex items-center justify-between gap-3">
78
+ <span className="text-muted-foreground">
79
+ {t('ws-invoices.coverage_range')}
80
+ </span>
81
+ <span className="text-right font-medium">{coverageRangeLabel}</span>
82
+ </div>
83
+ <div className="flex items-center justify-between gap-3">
84
+ <span className="text-muted-foreground">
85
+ {t('ws-invoices.valid_until')}
86
+ </span>
87
+ <span className="text-right font-medium">{validUntilLabel}</span>
88
+ </div>
89
+ </div>
90
+ </CardContent>
91
+ </Card>
92
+ );
93
+ }
@@ -16,14 +16,9 @@ import type {
16
16
  } from '../types';
17
17
  import type { UserGroup } from '../utils';
18
18
  import {
19
- getEffectiveAttendanceDays,
19
+ getBillableQuantityMapForGroupsRange,
20
20
  getEffectiveDays,
21
- getSessionsForMonth,
22
- getSessionsUntilMonth,
23
- getSubscriptionCoverageInvoiceForGroup,
24
21
  getTotalSessionsForGroups,
25
- isSubscriptionMonthPaidForGroup,
26
- parseLocalCalendarDate,
27
22
  } from '../utils';
28
23
 
29
24
  interface UseSubscriptionAutoSelectionProps {
@@ -42,6 +37,7 @@ interface UseSubscriptionAutoSelectionProps {
42
37
  created_at?: string | null;
43
38
  }[];
44
39
  onSelectedProductsChange: Dispatch<SetStateAction<SelectedProductItem[]>>;
40
+ prepaidMonthCount?: number;
45
41
  }
46
42
 
47
43
  export type UseSubscriptionAutoSelectionResult = undefined;
@@ -165,54 +161,59 @@ const computeGroupAttendanceDaysMap = (
165
161
  }[],
166
162
  selectedMonth: string,
167
163
  userAttendance: { status: string; date: string; group_id?: string }[],
168
- useAttendanceBased: boolean
164
+ useAttendanceBased: boolean,
165
+ prepaidMonthCount = 1
169
166
  ) => {
170
- const groupAttendanceDaysMap: Record<string, number> = {};
171
-
172
- for (const groupId of selectedGroupIds) {
173
- const group = userGroups.find(
174
- (g) => g.workspace_user_groups?.id === groupId
175
- );
176
- if (!group) continue;
177
-
178
- const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
179
- latestSubscriptionInvoices,
180
- groupId
181
- );
182
- const validUntil = latestInvoice?.valid_until
183
- ? parseLocalCalendarDate(latestInvoice.valid_until)
184
- : null;
167
+ return getBillableQuantityMapForGroupsRange({
168
+ groupIds: selectedGroupIds,
169
+ latestInvoices: latestSubscriptionInvoices,
170
+ prepaidMonthCount,
171
+ selectedMonth,
172
+ useAttendanceBased,
173
+ userAttendance,
174
+ userGroups,
175
+ });
176
+ };
185
177
 
186
- const isGroupPaid = isSubscriptionMonthPaidForGroup(
187
- groupId,
188
- selectedMonth,
189
- latestSubscriptionInvoices
190
- );
178
+ const sumQuantityMap = (quantityMap: Record<string, number>) =>
179
+ Object.values(quantityMap).reduce((total, value) => total + value, 0);
191
180
 
192
- const sessionsArray = group.workspace_user_groups?.sessions || [];
193
- const groupSessions = isGroupPaid
194
- ? 0
195
- : validUntil
196
- ? getSessionsUntilMonth(sessionsArray, selectedMonth, validUntil)
197
- : getSessionsForMonth(sessionsArray, selectedMonth);
198
-
199
- if (useAttendanceBased) {
200
- const groupAttendance = isGroupPaid
201
- ? []
202
- : userAttendance.filter((a) => {
203
- if (a.group_id !== groupId) return false;
204
- if (!validUntil) return true;
205
- const attendanceDate = parseLocalCalendarDate(a.date);
206
- return attendanceDate >= validUntil;
207
- });
208
- groupAttendanceDaysMap[groupId] =
209
- getEffectiveAttendanceDays(groupAttendance);
210
- } else {
211
- groupAttendanceDaysMap[groupId] = groupSessions;
212
- }
181
+ const computeFallbackAttendanceDays = ({
182
+ groupAttendanceDaysMap,
183
+ latestSubscriptionInvoices,
184
+ prepaidMonthCount,
185
+ selectedGroupIds,
186
+ selectedMonth,
187
+ useAttendanceBased,
188
+ userAttendance,
189
+ userGroups,
190
+ }: {
191
+ groupAttendanceDaysMap: Record<string, number>;
192
+ latestSubscriptionInvoices: {
193
+ group_id?: string;
194
+ valid_until?: string | null;
195
+ created_at?: string | null;
196
+ }[];
197
+ prepaidMonthCount: number;
198
+ selectedGroupIds: string[];
199
+ selectedMonth: string;
200
+ useAttendanceBased: boolean;
201
+ userAttendance: { status: string; date: string; group_id?: string }[];
202
+ userGroups: UserGroup[];
203
+ }): number => {
204
+ const mappedQuantity = sumQuantityMap(groupAttendanceDaysMap);
205
+ if (mappedQuantity > 0 || prepaidMonthCount > 1) {
206
+ return mappedQuantity;
213
207
  }
214
208
 
215
- return groupAttendanceDaysMap;
209
+ const totalSessions = getTotalSessionsForGroups(
210
+ userGroups,
211
+ selectedGroupIds,
212
+ selectedMonth,
213
+ latestSubscriptionInvoices
214
+ );
215
+
216
+ return getEffectiveDays(userAttendance, totalSessions, useAttendanceBased);
216
217
  };
217
218
 
218
219
  export function useSubscriptionAutoSelection({
@@ -227,6 +228,7 @@ export function useSubscriptionAutoSelection({
227
228
  userAttendance,
228
229
  latestSubscriptionInvoices,
229
230
  onSelectedProductsChange,
231
+ prepaidMonthCount = 1,
230
232
  }: UseSubscriptionAutoSelectionProps): UseSubscriptionAutoSelectionResult {
231
233
  const t = useTranslations();
232
234
  const previousGroupIdRef = useRef<string>('');
@@ -248,7 +250,8 @@ export function useSubscriptionAutoSelection({
248
250
  latestSubscriptionInvoices,
249
251
  selectedMonth,
250
252
  userAttendance,
251
- useAttendanceBased
253
+ useAttendanceBased,
254
+ prepaidMonthCount
252
255
  ),
253
256
  [
254
257
  sortedSelectedGroupIds,
@@ -257,6 +260,7 @@ export function useSubscriptionAutoSelection({
257
260
  selectedMonth,
258
261
  userAttendance,
259
262
  useAttendanceBased,
263
+ prepaidMonthCount,
260
264
  ]
261
265
  );
262
266
 
@@ -307,18 +311,22 @@ export function useSubscriptionAutoSelection({
307
311
 
308
312
  fallbackToastShownRef.current = false;
309
313
 
310
- const totalSessions = getTotalSessionsForGroups(
311
- userGroups,
312
- sortedSelectedGroupIds,
313
- selectedMonth,
314
- latestSubscriptionInvoices
315
- );
316
-
317
314
  const shouldUsePrefill =
318
- prefillAmount != null && !initialPrefillUsedRef.current;
315
+ prepaidMonthCount === 1 &&
316
+ prefillAmount != null &&
317
+ !initialPrefillUsedRef.current;
319
318
  const attendanceDays = shouldUsePrefill
320
319
  ? prefillAmount
321
- : getEffectiveDays(userAttendance, totalSessions, useAttendanceBased);
320
+ : computeFallbackAttendanceDays({
321
+ groupAttendanceDaysMap,
322
+ latestSubscriptionInvoices,
323
+ prepaidMonthCount,
324
+ selectedGroupIds: sortedSelectedGroupIds,
325
+ selectedMonth,
326
+ useAttendanceBased,
327
+ userAttendance,
328
+ userGroups,
329
+ });
322
330
 
323
331
  updateSelectedProducts(
324
332
  attendanceDays,
@@ -340,6 +348,7 @@ export function useSubscriptionAutoSelection({
340
348
  latestSubscriptionInvoices,
341
349
  updateSelectedProducts,
342
350
  groupAttendanceDaysMap,
351
+ prepaidMonthCount,
343
352
  ]);
344
353
 
345
354
  // Auto-add group products based on attendance when group is selected
@@ -352,22 +361,24 @@ export function useSubscriptionAutoSelection({
352
361
  return;
353
362
  }
354
363
 
355
- if (prefillAmount != null && !initialPrefillUsedRef.current) {
364
+ if (
365
+ prepaidMonthCount === 1 &&
366
+ prefillAmount != null &&
367
+ !initialPrefillUsedRef.current
368
+ ) {
356
369
  return;
357
370
  }
358
371
 
359
- const totalSessions = getTotalSessionsForGroups(
360
- userGroups,
361
- sortedSelectedGroupIds,
372
+ const attendanceDays = computeFallbackAttendanceDays({
373
+ groupAttendanceDaysMap,
374
+ latestSubscriptionInvoices,
375
+ prepaidMonthCount,
376
+ selectedGroupIds: sortedSelectedGroupIds,
362
377
  selectedMonth,
363
- latestSubscriptionInvoices
364
- );
365
-
366
- const attendanceDays = getEffectiveDays(
378
+ useAttendanceBased,
367
379
  userAttendance,
368
- totalSessions,
369
- useAttendanceBased
370
- );
380
+ userGroups,
381
+ });
371
382
 
372
383
  if (attendanceDays === 0) return;
373
384
 
@@ -409,5 +420,6 @@ export function useSubscriptionAutoSelection({
409
420
  onSelectedProductsChange,
410
421
  products,
411
422
  groupAttendanceDaysMap,
423
+ prepaidMonthCount,
412
424
  ]);
413
425
  }
@@ -3,11 +3,15 @@ import { useEffect, useRef } from 'react';
3
3
  import type { SelectedProductItem, UserGroupProducts } from '../types';
4
4
  import type { UserGroup } from '../utils';
5
5
  import {
6
+ formatCoverageRangeLabel,
6
7
  formatMonthLabel,
7
8
  getAttendanceStats,
9
+ getBillableAttendanceRecordsInRange,
10
+ getBillableSessionsForGroupsInRange,
11
+ getCoverageEndMonthValue,
12
+ getCoverageMonths,
8
13
  getSubscriptionCoverageInvoiceForGroup,
9
- getTotalSessionsForGroups,
10
- parseLocalCalendarDate,
14
+ isSubscriptionMonthPaidForGroup,
11
15
  } from '../utils';
12
16
 
13
17
  interface UseSubscriptionInvoiceContentProps {
@@ -26,6 +30,7 @@ interface UseSubscriptionInvoiceContentProps {
26
30
  locale: string;
27
31
  onContentChange: (content: string) => void;
28
32
  onNotesChange: (notes: string) => void;
33
+ prepaidMonthCount?: number;
29
34
  }
30
35
 
31
36
  export function useSubscriptionInvoiceContent({
@@ -41,6 +46,7 @@ export function useSubscriptionInvoiceContent({
41
46
  locale,
42
47
  onContentChange,
43
48
  onNotesChange,
49
+ prepaidMonthCount = 1,
44
50
  }: UseSubscriptionInvoiceContentProps): void {
45
51
  const t = useTranslations();
46
52
  const contentCallbackRef = useRef(onContentChange);
@@ -63,7 +69,18 @@ export function useSubscriptionInvoiceContent({
63
69
  const formatMonth = (monthStr: string) =>
64
70
  formatMonthLabel(monthStr, locale);
65
71
 
66
- // Group by month ranges
72
+ const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
73
+ const coverageEndMonth = getCoverageEndMonthValue(
74
+ selectedMonth,
75
+ prepaidMonthCount
76
+ );
77
+ const fallbackRangeLabel = formatCoverageRangeLabel({
78
+ locale,
79
+ prepaidMonthCount,
80
+ selectedMonth,
81
+ });
82
+
83
+ // Group by unpaid month ranges.
67
84
  const rangeToGroups = new Map<string, string[]>();
68
85
 
69
86
  selectedGroupIds.forEach((groupId) => {
@@ -77,12 +94,21 @@ export function useSubscriptionInvoiceContent({
77
94
  latestSubscriptionInvoices,
78
95
  groupId
79
96
  );
97
+ const unpaidMonths = coverageMonths.filter(
98
+ (month) =>
99
+ !isSubscriptionMonthPaidForGroup(
100
+ groupId,
101
+ month,
102
+ latestSubscriptionInvoices
103
+ )
104
+ );
80
105
 
81
- const startMonth = latestInvoice?.valid_until
82
- ? latestInvoice.valid_until.slice(0, 7)
83
- : null;
84
-
85
- const endMonth = selectedMonth;
106
+ const startMonth =
107
+ unpaidMonths[0] ??
108
+ latestInvoice?.valid_until?.slice(0, 7) ??
109
+ selectedMonth;
110
+ const endMonth =
111
+ unpaidMonths[unpaidMonths.length - 1] ?? coverageEndMonth;
86
112
 
87
113
  let rangeLabel = '';
88
114
  if (startMonth && startMonth < endMonth) {
@@ -90,7 +116,10 @@ export function useSubscriptionInvoiceContent({
90
116
  rangeLabel = `${formatMonth(startMonth)} - ${formatMonth(endMonth)}`;
91
117
  } else {
92
118
  // Single month
93
- rangeLabel = formatMonth(endMonth);
119
+ rangeLabel =
120
+ unpaidMonths.length === 0
121
+ ? fallbackRangeLabel
122
+ : formatMonth(endMonth);
94
123
  }
95
124
 
96
125
  if (!rangeToGroups.has(rangeLabel)) {
@@ -126,28 +155,23 @@ export function useSubscriptionInvoiceContent({
126
155
  }
127
156
 
128
157
  let autoNotes: string | null = null;
129
- if (selectedGroupIds.length > 0 && userAttendance.length > 0) {
130
- const filteredAttendance = userAttendance.filter((a) => {
131
- const latestInvoice = a.group_id
132
- ? getSubscriptionCoverageInvoiceForGroup(
133
- latestSubscriptionInvoices,
134
- a.group_id
135
- )
136
- : undefined;
137
- if (!latestInvoice?.valid_until) return true;
138
- const validUntil = parseLocalCalendarDate(latestInvoice.valid_until);
139
- const attendanceDate = parseLocalCalendarDate(a.date);
140
- return attendanceDate >= validUntil;
141
- });
142
-
158
+ if (selectedGroupIds.length > 0) {
159
+ const filteredAttendance = getBillableAttendanceRecordsInRange(
160
+ userAttendance,
161
+ selectedGroupIds,
162
+ selectedMonth,
163
+ prepaidMonthCount,
164
+ latestSubscriptionInvoices
165
+ );
143
166
  const attendanceStats = getAttendanceStats(filteredAttendance);
144
167
  const attendanceDays = attendanceStats.present + attendanceStats.late;
145
- const totalSessions = getTotalSessionsForGroups(
168
+ const totalSessions = getBillableSessionsForGroupsInRange(
146
169
  userGroups,
147
170
  selectedGroupIds,
148
171
  selectedMonth,
172
+ prepaidMonthCount,
149
173
  latestSubscriptionInvoices
150
- );
174
+ ).length;
151
175
  autoNotes = t('ws-invoices.attendance_summary_note', {
152
176
  attended: attendanceDays,
153
177
  total: totalSessions,
@@ -192,5 +216,6 @@ export function useSubscriptionInvoiceContent({
192
216
  locale,
193
217
  t,
194
218
  latestSubscriptionInvoices,
219
+ prepaidMonthCount,
195
220
  ]);
196
221
  }
@@ -501,10 +501,18 @@ export const useSubscriptionInvoiceContext = (
501
501
  wsId: string,
502
502
  userId: string,
503
503
  groupIds: string[],
504
- month: string
504
+ month: string,
505
+ monthCount = 1
505
506
  ) => {
506
507
  return useQuery({
507
- queryKey: ['subscription-invoice-context', wsId, userId, groupIds, month],
508
+ queryKey: [
509
+ 'subscription-invoice-context',
510
+ wsId,
511
+ userId,
512
+ groupIds,
513
+ month,
514
+ monthCount,
515
+ ],
508
516
  queryFn: async () => {
509
517
  if (!wsId || !userId || groupIds.length === 0 || !month) {
510
518
  return {
@@ -524,6 +532,7 @@ export const useSubscriptionInvoiceContext = (
524
532
  return getSubscriptionInvoiceContext(wsId, {
525
533
  groupIds,
526
534
  month,
535
+ monthCount,
527
536
  userId,
528
537
  });
529
538
  },
@@ -61,6 +61,7 @@ export interface CreateInvoicePayload {
61
61
  export interface CreateSubscriptionInvoicePayload extends CreateInvoicePayload {
62
62
  customer_id: string;
63
63
  group_ids: string[];
64
+ prepaid_month_count?: number;
64
65
  selected_month: string;
65
66
  }
66
67
 
@@ -72,11 +73,15 @@ export interface InvoiceMutationResponse {
72
73
  subtotal: number;
73
74
  total: number;
74
75
  };
76
+ coverage_end_month?: string;
77
+ coverage_start_month?: string;
75
78
  frontend_values?: {
76
79
  discount_amount?: number;
77
80
  subtotal?: number;
78
81
  total?: number;
79
82
  };
83
+ prepaid_month_count?: number;
84
+ valid_until?: string;
80
85
  values_recalculated?: boolean;
81
86
  };
82
87
  invoice_id: string;