@tuturuuu/ui 0.33.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 +13 -0
- package/package.json +5 -5
- 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/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/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-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/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +2 -0
- 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.tsx +3 -6
- 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/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
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSubscriptionCoverageInvoiceForGroup,
|
|
3
|
+
isSubscriptionMonthPaidForGroup,
|
|
4
|
+
type SubscriptionCoverageInvoice,
|
|
5
|
+
} from './subscription-coverage';
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
getSubscriptionCoverageInvoiceForGroup,
|
|
9
|
+
isSubscriptionMonthCoveredByInvoice,
|
|
10
|
+
isSubscriptionMonthPaidForGroup,
|
|
11
|
+
isSubscriptionRangeFullyPaidForGroups,
|
|
12
|
+
isSubscriptionRangePaidForGroup,
|
|
13
|
+
type SubscriptionCoverageInvoice,
|
|
14
|
+
} from './subscription-coverage';
|
|
15
|
+
|
|
1
16
|
import type { Database } from '@tuturuuu/types';
|
|
2
17
|
import {
|
|
3
18
|
formatMonthLabel,
|
|
@@ -48,12 +63,6 @@ export type UserGroup = {
|
|
|
48
63
|
workspace_user_groups: WorkspaceUserGroup | null;
|
|
49
64
|
};
|
|
50
65
|
|
|
51
|
-
export type SubscriptionCoverageInvoice = {
|
|
52
|
-
created_at?: string | null;
|
|
53
|
-
group_id?: string;
|
|
54
|
-
valid_until?: string | null;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
66
|
export type BillableSession = {
|
|
58
67
|
date: string;
|
|
59
68
|
groupId: string;
|
|
@@ -114,99 +123,6 @@ export const resolveSubscriptionInvoiceCategoryId = ({
|
|
|
114
123
|
return defaultCategoryId ?? '';
|
|
115
124
|
};
|
|
116
125
|
|
|
117
|
-
const getComparableTimestamp = (value: string | null | undefined): number => {
|
|
118
|
-
if (!value) return 0;
|
|
119
|
-
const timestamp = new Date(value).getTime();
|
|
120
|
-
return Number.isNaN(timestamp) ? 0 : timestamp;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const getComparableValidUntilTimestamp = (
|
|
124
|
-
invoice: SubscriptionCoverageInvoice
|
|
125
|
-
): number => {
|
|
126
|
-
const validUntil = parseLocalCalendarDate(invoice.valid_until);
|
|
127
|
-
return Number.isNaN(validUntil.getTime()) ? 0 : validUntil.getTime();
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
export const getSubscriptionCoverageInvoiceForGroup = (
|
|
131
|
-
latestInvoices: SubscriptionCoverageInvoice[],
|
|
132
|
-
groupId: string
|
|
133
|
-
): SubscriptionCoverageInvoice | undefined =>
|
|
134
|
-
latestInvoices
|
|
135
|
-
.filter((invoice) => invoice.group_id === groupId)
|
|
136
|
-
.filter((invoice) => getComparableValidUntilTimestamp(invoice) > 0)
|
|
137
|
-
.sort((a, b) => {
|
|
138
|
-
const validUntilDiff =
|
|
139
|
-
getComparableValidUntilTimestamp(b) -
|
|
140
|
-
getComparableValidUntilTimestamp(a);
|
|
141
|
-
if (validUntilDiff !== 0) return validUntilDiff;
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
getComparableTimestamp(b.created_at) -
|
|
145
|
-
getComparableTimestamp(a.created_at)
|
|
146
|
-
);
|
|
147
|
-
})[0];
|
|
148
|
-
|
|
149
|
-
export const isSubscriptionMonthCoveredByInvoice = (
|
|
150
|
-
selectedMonth: string,
|
|
151
|
-
invoice: SubscriptionCoverageInvoice | null | undefined
|
|
152
|
-
): boolean => {
|
|
153
|
-
if (!invoice?.valid_until) return false;
|
|
154
|
-
|
|
155
|
-
const selectedMonthStart = getMonthStartDate(selectedMonth);
|
|
156
|
-
const validUntilMonthStart = getMonthStartDate(invoice.valid_until);
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
Number.isNaN(selectedMonthStart.getTime()) ||
|
|
160
|
-
Number.isNaN(validUntilMonthStart.getTime())
|
|
161
|
-
) {
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return selectedMonthStart < validUntilMonthStart;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
export const isSubscriptionMonthPaidForGroup = (
|
|
169
|
-
groupId: string,
|
|
170
|
-
selectedMonth: string,
|
|
171
|
-
latestInvoices: SubscriptionCoverageInvoice[]
|
|
172
|
-
): boolean =>
|
|
173
|
-
isSubscriptionMonthCoveredByInvoice(
|
|
174
|
-
selectedMonth,
|
|
175
|
-
getSubscriptionCoverageInvoiceForGroup(latestInvoices, groupId)
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
export const isSubscriptionRangePaidForGroup = (
|
|
179
|
-
groupId: string,
|
|
180
|
-
selectedMonth: string,
|
|
181
|
-
prepaidMonthCount: number,
|
|
182
|
-
latestInvoices: SubscriptionCoverageInvoice[]
|
|
183
|
-
): boolean => {
|
|
184
|
-
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
185
|
-
if (coverageMonths.length === 0) return false;
|
|
186
|
-
|
|
187
|
-
return coverageMonths.every((month) =>
|
|
188
|
-
isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices)
|
|
189
|
-
);
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
export const isSubscriptionRangeFullyPaidForGroups = (
|
|
193
|
-
groupIds: string[],
|
|
194
|
-
selectedMonth: string,
|
|
195
|
-
prepaidMonthCount: number,
|
|
196
|
-
latestInvoices: SubscriptionCoverageInvoice[]
|
|
197
|
-
): boolean => {
|
|
198
|
-
if (groupIds.length === 0) return false;
|
|
199
|
-
|
|
200
|
-
return groupIds.every((groupId) =>
|
|
201
|
-
isSubscriptionRangePaidForGroup(
|
|
202
|
-
groupId,
|
|
203
|
-
selectedMonth,
|
|
204
|
-
prepaidMonthCount,
|
|
205
|
-
latestInvoices
|
|
206
|
-
)
|
|
207
|
-
);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
126
|
export const getAttendanceStats = (
|
|
211
127
|
attendance: AttendanceRecord[]
|
|
212
128
|
): AttendanceStats => {
|
|
@@ -251,7 +167,7 @@ const getGroupValidUntilDate = (
|
|
|
251
167
|
groupId: string
|
|
252
168
|
): Date | null => {
|
|
253
169
|
const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
|
|
254
|
-
latestInvoices,
|
|
170
|
+
latestInvoices.filter((invoice) => invoice.covered_months == null),
|
|
255
171
|
groupId
|
|
256
172
|
);
|
|
257
173
|
if (!latestInvoice?.valid_until) return null;
|
|
@@ -285,7 +201,11 @@ export const getBillableSessionsForGroups = (
|
|
|
285
201
|
(candidate) => candidate.workspace_user_groups?.id === groupId
|
|
286
202
|
)?.workspace_user_groups;
|
|
287
203
|
|
|
288
|
-
if (
|
|
204
|
+
if (
|
|
205
|
+
!group?.sessions?.length ||
|
|
206
|
+
isSubscriptionMonthPaidForGroup(groupId, selectedMonth, latestInvoices)
|
|
207
|
+
)
|
|
208
|
+
return [];
|
|
289
209
|
|
|
290
210
|
const validUntil = getGroupValidUntilDate(latestInvoices, groupId);
|
|
291
211
|
|
|
@@ -330,7 +250,12 @@ export const getBillableAttendanceRecords = (
|
|
|
330
250
|
|
|
331
251
|
return attendance.filter((record) => {
|
|
332
252
|
const groupId = record.group_id;
|
|
333
|
-
if (
|
|
253
|
+
if (
|
|
254
|
+
!groupId ||
|
|
255
|
+
!groupIds.includes(groupId) ||
|
|
256
|
+
isSubscriptionMonthPaidForGroup(groupId, selectedMonth, latestInvoices)
|
|
257
|
+
)
|
|
258
|
+
return false;
|
|
334
259
|
|
|
335
260
|
const attendanceDate = parseLocalCalendarDate(record.date);
|
|
336
261
|
if (!isDateInMonth(attendanceDate, selectedMonth)) return false;
|
|
@@ -668,9 +593,10 @@ const EXPIRING_SOON_DAYS = 14;
|
|
|
668
593
|
export type GroupPaymentStatus = 'active' | 'expiringSoon' | 'expired';
|
|
669
594
|
export function getGroupPaymentStatus(
|
|
670
595
|
group: WorkspaceUserGroup | null,
|
|
671
|
-
latestInvoice:
|
|
672
|
-
|
|
673
|
-
|
|
596
|
+
latestInvoice: SubscriptionCoverageInvoice | undefined,
|
|
597
|
+
coverageInvoices: SubscriptionCoverageInvoice[] = latestInvoice
|
|
598
|
+
? [latestInvoice]
|
|
599
|
+
: []
|
|
674
600
|
): GroupPaymentStatus {
|
|
675
601
|
const today = new Date();
|
|
676
602
|
today.setHours(0, 0, 0, 0);
|
|
@@ -685,9 +611,25 @@ export function getGroupPaymentStatus(
|
|
|
685
611
|
if (today > endDate) return 'expired';
|
|
686
612
|
}
|
|
687
613
|
|
|
688
|
-
|
|
614
|
+
let validUntil = latestInvoice?.valid_until
|
|
689
615
|
? parseLocalCalendarDate(latestInvoice.valid_until)
|
|
690
616
|
: null;
|
|
617
|
+
if (coverageInvoices.some((invoice) => invoice.covered_months != null)) {
|
|
618
|
+
const cursor = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
619
|
+
const month = () =>
|
|
620
|
+
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
|
|
621
|
+
if (!isSubscriptionMonthPaidForGroup(group.id, month(), coverageInvoices))
|
|
622
|
+
return 'expired';
|
|
623
|
+
// Walk contiguous paid months so a later payment cannot conceal a gap.
|
|
624
|
+
for (
|
|
625
|
+
let i = 0;
|
|
626
|
+
i < 1200 &&
|
|
627
|
+
isSubscriptionMonthPaidForGroup(group.id, month(), coverageInvoices);
|
|
628
|
+
i++
|
|
629
|
+
)
|
|
630
|
+
cursor.setMonth(cursor.getMonth() + 1);
|
|
631
|
+
validUntil = cursor;
|
|
632
|
+
}
|
|
691
633
|
if (validUntil) {
|
|
692
634
|
validUntil.setHours(0, 0, 0, 0);
|
|
693
635
|
const daysUntilExpiry = Math.ceil(
|
|
@@ -12,8 +12,9 @@ import timezone from 'dayjs/plugin/timezone';
|
|
|
12
12
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { MIN_COLUMN_WIDTH } from './config';
|
|
14
14
|
import { CalendarEventProviderIcon } from './event-provider-display';
|
|
15
|
-
import {
|
|
15
|
+
import { LocationTimeline } from './location-timeline';
|
|
16
16
|
import { useCalendarSettings } from './settings/settings-context';
|
|
17
|
+
import { getEventLocationType } from './working-location';
|
|
17
18
|
|
|
18
19
|
dayjs.extend(isBetween);
|
|
19
20
|
dayjs.extend(isSameOrAfter);
|
|
@@ -22,7 +23,6 @@ dayjs.extend(timezone);
|
|
|
22
23
|
|
|
23
24
|
const MAX_EVENTS_DISPLAY = 2;
|
|
24
25
|
|
|
25
|
-
// Define types for better type safety
|
|
26
26
|
interface EventSpan {
|
|
27
27
|
event: CalendarEvent;
|
|
28
28
|
startIndex: number;
|
|
@@ -490,11 +490,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
490
490
|
// Location events will be rendered separately as a compact timeline strip
|
|
491
491
|
// Extract location spans before filtering them out for row assignment
|
|
492
492
|
const locationSpansFromMerge = mergedTempSpans
|
|
493
|
-
.filter((span) =>
|
|
493
|
+
.filter((span) => getEventLocationType(span.event) !== null)
|
|
494
494
|
.map((span) => ({ ...span, row: 0 })); // Give them row 0 as placeholder
|
|
495
495
|
|
|
496
496
|
const nonLocationSpans = mergedTempSpans.filter(
|
|
497
|
-
(span) =>
|
|
497
|
+
(span) => getEventLocationType(span.event) === null
|
|
498
498
|
);
|
|
499
499
|
|
|
500
500
|
// Sort: 1) by span length (longer events first for better packing), 2) by start date
|
|
@@ -628,7 +628,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
628
628
|
// Get non-location spans for regular event display
|
|
629
629
|
const regularSpans = useMemo(() => {
|
|
630
630
|
return eventLayout.spans.filter(
|
|
631
|
-
(span) =>
|
|
631
|
+
(span) => getEventLocationType(span.event) === null
|
|
632
632
|
);
|
|
633
633
|
}, [eventLayout.spans]);
|
|
634
634
|
|
|
@@ -22,6 +22,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
|
|
22
22
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
|
23
23
|
import timezone from 'dayjs/plugin/timezone';
|
|
24
24
|
import { useCallback, useEffect, useState } from 'react';
|
|
25
|
+
import { getEventLocationType } from './working-location';
|
|
25
26
|
|
|
26
27
|
dayjs.extend(timezone);
|
|
27
28
|
dayjs.extend(isBetween);
|
|
@@ -47,19 +48,6 @@ interface EventSpan {
|
|
|
47
48
|
mergedEventIds?: string[];
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
type LocationType = 'home' | 'office' | 'school' | 'custom' | null;
|
|
51
|
-
|
|
52
|
-
// Helper to determine location type
|
|
53
|
-
export const getLocationType = (title: string): LocationType => {
|
|
54
|
-
const normalizedTitle = title.toLowerCase().trim();
|
|
55
|
-
if (normalizedTitle === 'home') return 'home';
|
|
56
|
-
if (normalizedTitle === 'office' || normalizedTitle === 'work')
|
|
57
|
-
return 'office';
|
|
58
|
-
if (normalizedTitle === 'school') return 'school';
|
|
59
|
-
if (title.startsWith('📍') || title.startsWith('Location: ')) return 'custom';
|
|
60
|
-
return null;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
51
|
// Location colors using dynamic theme tokens
|
|
64
52
|
const locationStyles = {
|
|
65
53
|
home: {
|
|
@@ -156,7 +144,7 @@ const LocationPill = ({
|
|
|
156
144
|
);
|
|
157
145
|
|
|
158
146
|
const { event, startIndex, span } = eventSpan;
|
|
159
|
-
const locationType =
|
|
147
|
+
const locationType = getEventLocationType(event);
|
|
160
148
|
const isHome = locationType === 'home';
|
|
161
149
|
const isOffice = locationType === 'office';
|
|
162
150
|
const isSchool = locationType === 'school';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { getEventLocationType } from './working-location';
|
|
4
|
+
|
|
5
|
+
const event = (overrides: Partial<CalendarEvent>): CalendarEvent => ({
|
|
6
|
+
id: 'event',
|
|
7
|
+
start_at: '2026-09-25T00:00:00Z',
|
|
8
|
+
end_at: '2026-09-26T00:00:00Z',
|
|
9
|
+
...overrides,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('Google working locations', () => {
|
|
13
|
+
it('uses provider metadata even when the title differs', () => {
|
|
14
|
+
expect(
|
|
15
|
+
getEventLocationType(
|
|
16
|
+
event({
|
|
17
|
+
title: 'Remote today',
|
|
18
|
+
scheduling_metadata: {
|
|
19
|
+
google_event_type: 'workingLocation',
|
|
20
|
+
google_working_location_type: 'homeOffice',
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
).toBe('home');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('keeps legacy Home and Office location events visible', () => {
|
|
28
|
+
expect(getEventLocationType(event({ title: 'Home' }))).toBe('home');
|
|
29
|
+
expect(getEventLocationType(event({ title: 'Office' }))).toBe('office');
|
|
30
|
+
expect(getEventLocationType(event({ title: 'School' }))).toBe('school');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('displays custom Google School locations distinctly', () => {
|
|
34
|
+
expect(
|
|
35
|
+
getEventLocationType(
|
|
36
|
+
event({
|
|
37
|
+
title: 'Campus',
|
|
38
|
+
scheduling_metadata: {
|
|
39
|
+
google_event_type: 'workingLocation',
|
|
40
|
+
google_working_location_type: 'customLocation',
|
|
41
|
+
google_working_location_label: 'School',
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
).toBe('school');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
|
|
3
|
+
export type LocationType = 'home' | 'office' | 'school' | 'custom' | null;
|
|
4
|
+
|
|
5
|
+
export function getLocationType(title: string): LocationType {
|
|
6
|
+
const normalizedTitle = title.toLowerCase().trim();
|
|
7
|
+
if (normalizedTitle === 'home') return 'home';
|
|
8
|
+
if (normalizedTitle === 'office' || normalizedTitle === 'work')
|
|
9
|
+
return 'office';
|
|
10
|
+
if (normalizedTitle === 'school') return 'school';
|
|
11
|
+
if (title.startsWith('📍') || title.startsWith('Location: ')) return 'custom';
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getEventLocationType(event: CalendarEvent): LocationType {
|
|
16
|
+
if (event.scheduling_metadata?.google_event_type === 'workingLocation') {
|
|
17
|
+
switch (event.scheduling_metadata.google_working_location_type) {
|
|
18
|
+
case 'homeOffice':
|
|
19
|
+
return 'home';
|
|
20
|
+
case 'officeLocation':
|
|
21
|
+
return 'office';
|
|
22
|
+
case 'customLocation': {
|
|
23
|
+
const label = event.scheduling_metadata.google_working_location_label;
|
|
24
|
+
return (typeof label === 'string' &&
|
|
25
|
+
label.trim().toLowerCase() === 'school') ||
|
|
26
|
+
event.title?.trim().toLowerCase() === 'school'
|
|
27
|
+
? 'school'
|
|
28
|
+
: 'custom';
|
|
29
|
+
}
|
|
30
|
+
default:
|
|
31
|
+
return 'custom';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return getLocationType(event.title ?? '');
|
|
35
|
+
}
|