@tuturuuu/ui 0.38.0 → 0.39.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 (93) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  4. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  5. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  6. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  7. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  8. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  9. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  10. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  11. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  12. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  13. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  14. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  15. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  16. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  17. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  18. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  20. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  21. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  22. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  23. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  24. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  25. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  26. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  27. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  28. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  29. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  30. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  31. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  32. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  33. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  34. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  35. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  36. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  37. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  38. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  39. package/src/components/ui/finance/invoices/types.ts +3 -3
  40. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  41. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  43. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  44. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  45. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  46. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  47. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  48. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  49. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  50. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  51. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
  52. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  53. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  54. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  55. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  56. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  57. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  58. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  59. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  60. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  61. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  62. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  63. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  64. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  65. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  66. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  67. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  68. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  69. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
  70. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
  71. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  72. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  73. package/src/components/ui/text-editor/editor.tsx +3 -0
  74. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  75. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  76. package/src/components/ui/text-editor/tool-bar.tsx +18 -74
  77. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  78. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  79. package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
  80. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
  81. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  82. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  83. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
  84. package/src/hooks/calendar-connection-types.ts +49 -0
  85. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  86. package/src/hooks/calendar-event-write-payload.ts +33 -0
  87. package/src/hooks/collaboration-document-hydration.ts +35 -0
  88. package/src/hooks/supabase-provider.ts +39 -41
  89. package/src/hooks/use-calendar-sync.tsx +41 -62
  90. package/src/hooks/use-calendar.tsx +24 -38
  91. package/src/hooks/use-local-storage.ts +9 -11
  92. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  93. package/src/hooks/use-yjs-collaboration.ts +23 -0
@@ -0,0 +1,245 @@
1
+ import type {
2
+ CreateNativeCalendarSeriesPayload,
3
+ MutateNativeCalendarSeriesPayload,
4
+ NativeCalendarSeries,
5
+ } from '@tuturuuu/internal-api/calendar-series';
6
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
7
+ import type {
8
+ CalendarRecurrenceRule,
9
+ CalendarWeekday,
10
+ } from '@tuturuuu/types/primitives/calendar-recurrence';
11
+ import {
12
+ CalendarRecurrenceAnchorSchema,
13
+ inspectCalendarRecurrenceSlot,
14
+ validateCalendarRecurrence,
15
+ } from '@tuturuuu/utils/calendar-recurrence';
16
+ import {
17
+ recurrenceInitialTimes,
18
+ recurrenceInstantLocal,
19
+ recurrenceLocalAnchor,
20
+ recurrenceLocalParts,
21
+ recurrenceMidnightRange,
22
+ } from '@tuturuuu/utils/calendar-recurrence-local';
23
+ export type RecurrenceScope = 'this' | 'all' | 'future';
24
+ export interface RecurrenceDraft {
25
+ title: string;
26
+ description: string;
27
+ location: string;
28
+ startLocal: string;
29
+ endLocal: string;
30
+ timeZone: string;
31
+ allDay: boolean;
32
+ frequency: CalendarRecurrenceRule['frequency'];
33
+ interval: string;
34
+ weekdays: CalendarWeekday[];
35
+ retainedRule?: CalendarRecurrenceRule;
36
+ monthDay: string;
37
+ monthPattern: 'day' | 'weekday';
38
+ weekIndex: string;
39
+ month: string;
40
+ weekStartsOn?: CalendarWeekday;
41
+ monthDayOverflow?: CalendarRecurrenceRule['monthDayOverflow'];
42
+ endType: CalendarRecurrenceRule['end']['type'];
43
+ endValue: string;
44
+ }
45
+ const weekdays: CalendarWeekday[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
46
+ export function newRecurrenceDraft(
47
+ timeZone: string,
48
+ now = new Date()
49
+ ): RecurrenceDraft {
50
+ const start = recurrenceInitialTimes(timeZone, now);
51
+ return {
52
+ title: '',
53
+ description: '',
54
+ location: '',
55
+ startLocal: start.startLocal,
56
+ endLocal: start.endLocal,
57
+ timeZone,
58
+ allDay: false,
59
+ frequency: 'weekly',
60
+ interval: '1',
61
+ weekdays: [weekdays[start.dayOfWeek - 1]!],
62
+ monthDay: String(start.day),
63
+ monthPattern: 'day',
64
+ weekIndex: String(
65
+ Math.ceil(start.day / 7) > 4 ? -1 : Math.ceil(start.day / 7)
66
+ ),
67
+ month: '',
68
+ monthDayOverflow: 'skip',
69
+ endType: 'never',
70
+ endValue: '',
71
+ };
72
+ }
73
+ export function recurrenceEditDraft(
74
+ series: NativeCalendarSeries,
75
+ event: CalendarEvent,
76
+ scope: RecurrenceScope
77
+ ): RecurrenceDraft {
78
+ if (!event.originalStartLocal || event.seriesId !== series.id)
79
+ throw new Error('Invalid recurrence identity');
80
+ const rule =
81
+ scope === 'future'
82
+ ? inspectCalendarRecurrenceSlot({
83
+ rule: series.rule,
84
+ anchor: series.anchor,
85
+ originalStartLocal: event.originalStartLocal,
86
+ }).remainingRule
87
+ : series.rule;
88
+ const local = (instant: string) =>
89
+ recurrenceInstantLocal(instant, rule.timeZone);
90
+ const anchor =
91
+ scope === 'all'
92
+ ? series.anchor
93
+ : {
94
+ startLocal: local(event.start_at),
95
+ endLocal: local(event.end_at),
96
+ allDay: series.anchor.allDay,
97
+ };
98
+ const payload = scope === 'all' ? series.payload : event;
99
+ return {
100
+ monthPattern: rule.weekIndex ? 'weekday' : 'day',
101
+ weekIndex: String(
102
+ rule.weekIndex ??
103
+ (Math.ceil(recurrenceLocalParts(anchor.startLocal).day / 7) > 4
104
+ ? -1
105
+ : Math.ceil(recurrenceLocalParts(anchor.startLocal).day / 7))
106
+ ),
107
+ month: rule.month === undefined ? '' : String(rule.month),
108
+ weekStartsOn: rule.weekStartsOn,
109
+ monthDayOverflow: rule.monthDayOverflow,
110
+ title: payload.title ?? '',
111
+ description: payload.description ?? '',
112
+ location: payload.location ?? '',
113
+ startLocal: anchor.startLocal.slice(0, 16),
114
+ endLocal: anchor.endLocal.slice(0, 16),
115
+ timeZone: rule.timeZone,
116
+ allDay: anchor.allDay,
117
+ frequency: rule.frequency,
118
+ interval: String(rule.interval),
119
+ weekdays: rule.weekdays ?? [
120
+ weekdays[recurrenceLocalParts(anchor.startLocal).dayOfWeek - 1]!,
121
+ ],
122
+ monthDay: String(
123
+ rule.monthDay ?? recurrenceLocalParts(anchor.startLocal).day
124
+ ),
125
+ endType: rule.end.type,
126
+ endValue:
127
+ rule.end.type === 'count'
128
+ ? String(rule.end.count)
129
+ : rule.end.type === 'until'
130
+ ? rule.end.date
131
+ : '',
132
+ };
133
+ }
134
+ export function recurrenceDraftPayload(
135
+ draft: RecurrenceDraft,
136
+ occurrenceOnly = false
137
+ ) {
138
+ if (!draft.title.trim() || draft.title.length > 1000)
139
+ throw new Error('Invalid title');
140
+ const start = recurrenceLocalParts(draft.startLocal);
141
+ const rule: CalendarRecurrenceRule = {
142
+ version: 1,
143
+ frequency: draft.frequency,
144
+ interval: Number(draft.interval),
145
+ timeZone: draft.timeZone,
146
+ end:
147
+ draft.endType === 'count'
148
+ ? { type: 'count', count: Number(draft.endValue) }
149
+ : draft.endType === 'until'
150
+ ? { type: 'until', date: draft.endValue }
151
+ : { type: 'never' },
152
+ };
153
+ if (rule.frequency === 'weekly') {
154
+ rule.weekdays = draft.weekdays;
155
+ if (draft.weekStartsOn !== undefined)
156
+ rule.weekStartsOn = draft.weekStartsOn;
157
+ }
158
+ if (rule.frequency === 'monthly' || rule.frequency === 'yearly') {
159
+ if (draft.monthPattern === 'weekday') {
160
+ rule.weekdays = draft.weekdays;
161
+ rule.weekIndex = Number(
162
+ draft.weekIndex
163
+ ) as CalendarRecurrenceRule['weekIndex'];
164
+ } else {
165
+ rule.monthDay = Number(draft.monthDay);
166
+ if (draft.monthDayOverflow !== undefined)
167
+ rule.monthDayOverflow = draft.monthDayOverflow;
168
+ }
169
+ }
170
+ if (rule.frequency === 'yearly')
171
+ rule.month = draft.month ? Number(draft.month) : start.month;
172
+ const anchor = recurrenceLocalAnchor(
173
+ draft.startLocal,
174
+ draft.endLocal,
175
+ draft.allDay
176
+ );
177
+ if (occurrenceOnly) CalendarRecurrenceAnchorSchema.parse(anchor);
178
+ else validateCalendarRecurrence(draft.retainedRule ?? rule, anchor);
179
+ return {
180
+ rule: draft.retainedRule ?? rule,
181
+ anchor,
182
+ event: {
183
+ title: draft.title.trim(),
184
+ description: draft.description,
185
+ location: draft.location || null,
186
+ },
187
+ };
188
+ }
189
+ export function recurrenceMutation(
190
+ draft: RecurrenceDraft,
191
+ series: NativeCalendarSeries,
192
+ occurrence: CalendarEvent,
193
+ scope: RecurrenceScope,
194
+ requestId: string,
195
+ action: 'update' | 'delete'
196
+ ): MutateNativeCalendarSeriesPayload {
197
+ if (occurrence.seriesId !== series.id || !occurrence.originalStartLocal)
198
+ throw new Error('Invalid recurrence identity');
199
+ const identity = {
200
+ requestId,
201
+ expectedRevision: series.revision,
202
+ scope,
203
+ originalStartLocal: occurrence.originalStartLocal,
204
+ };
205
+ if (action === 'delete') return identity;
206
+ const payload = recurrenceDraftPayload(draft, scope === 'this');
207
+ return {
208
+ ...identity,
209
+ event: payload.event,
210
+ anchor: payload.anchor,
211
+ ...(scope === 'this' ? {} : { rule: payload.rule }),
212
+ };
213
+ }
214
+ /** A retry of unchanged intent reuses its key; any actor/workspace/revision or draft change does not. */
215
+ export function recurrenceRequestIdentity(
216
+ newId: () => string = () => crypto.randomUUID()
217
+ ) {
218
+ let intent: string | undefined, id: string | undefined;
219
+ return (
220
+ scope: string,
221
+ payload:
222
+ | Omit<CreateNativeCalendarSeriesPayload, 'requestId'>
223
+ | Omit<MutateNativeCalendarSeriesPayload, 'requestId'>,
224
+ action: string
225
+ ) => {
226
+ const next = JSON.stringify([scope, action, payload]);
227
+ if (next !== intent) {
228
+ intent = next;
229
+ id = newId();
230
+ }
231
+ return id!;
232
+ };
233
+ }
234
+
235
+ export function recurrenceAllDay(
236
+ draft: RecurrenceDraft,
237
+ allDay: boolean
238
+ ): RecurrenceDraft {
239
+ if (!allDay) return { ...draft, allDay };
240
+ return {
241
+ ...draft,
242
+ allDay,
243
+ ...recurrenceMidnightRange(draft.startLocal, draft.endLocal),
244
+ };
245
+ }
@@ -0,0 +1,142 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { type PropsWithChildren, useState } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ newRecurrenceDraft,
7
+ type RecurrenceDraft,
8
+ recurrenceDraftPayload,
9
+ } from './native-recurrence-model';
10
+ import { NativeRecurrencePatternFields } from './native-recurrence-pattern-fields';
11
+
12
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
13
+ vi.mock('../../select', () => ({
14
+ Select: ({
15
+ children,
16
+ value,
17
+ onValueChange,
18
+ }: PropsWithChildren<{
19
+ value: string;
20
+ onValueChange: (value: string) => void;
21
+ }>) => (
22
+ <select
23
+ aria-label={value}
24
+ value={value}
25
+ onChange={(event) => onValueChange(event.target.value)}
26
+ >
27
+ {children}
28
+ </select>
29
+ ),
30
+ SelectContent: ({ children }: PropsWithChildren) => <>{children}</>,
31
+ SelectTrigger: () => null,
32
+ SelectValue: () => null,
33
+ SelectItem: ({ children, value }: PropsWithChildren<{ value: string }>) => (
34
+ <option value={value}>{children}</option>
35
+ ),
36
+ }));
37
+ vi.mock('../../checkbox', () => ({
38
+ Checkbox: ({
39
+ checked,
40
+ onCheckedChange,
41
+ }: {
42
+ checked: boolean;
43
+ onCheckedChange: (value: boolean) => void;
44
+ }) => (
45
+ <input
46
+ type="checkbox"
47
+ checked={checked}
48
+ onChange={(event) => onCheckedChange(event.target.checked)}
49
+ />
50
+ ),
51
+ }));
52
+ afterEach(cleanup);
53
+ function Form({ initial }: { initial?: Partial<RecurrenceDraft> }) {
54
+ const [draft, setDraft] = useState({
55
+ ...newRecurrenceDraft('UTC', new Date('2026-03-27T09:00:00Z')),
56
+ title: 'Review',
57
+ frequency: 'monthly' as const,
58
+ ...initial,
59
+ });
60
+ const [result, setResult] = useState('');
61
+ return (
62
+ <>
63
+ <NativeRecurrencePatternFields draft={draft} setDraft={setDraft} />
64
+ <button
65
+ type="button"
66
+ onClick={() =>
67
+ setResult(JSON.stringify(recurrenceDraftPayload(draft).rule))
68
+ }
69
+ >
70
+ Save
71
+ </button>
72
+ <output>{result}</output>
73
+ </>
74
+ );
75
+ }
76
+ describe('relative recurrence pattern controls', () => {
77
+ it('changes an absolute monthly series to the last Friday without changing its start', () => {
78
+ render(<Form />);
79
+ fireEvent.change(screen.getByLabelText('day'), {
80
+ target: { value: 'weekday' },
81
+ });
82
+ fireEvent.change(screen.getByLabelText('4'), { target: { value: '-1' } });
83
+ expect(screen.getByText('relativeWeekdayHelp')).toBeInTheDocument();
84
+ expect(screen.queryByLabelText('monthDay')).not.toBeInTheDocument();
85
+ fireEvent.click(screen.getByText('Save'));
86
+ expect(JSON.parse(screen.getByRole('status').textContent!)).toMatchObject({
87
+ weekdays: ['FR'],
88
+ weekIndex: -1,
89
+ frequency: 'monthly',
90
+ });
91
+ });
92
+ it('edits a yearly relative rule with explicit month and retains ending count', () => {
93
+ render(
94
+ <Form
95
+ initial={{
96
+ frequency: 'yearly',
97
+ monthPattern: 'weekday',
98
+ weekIndex: '-1',
99
+ month: '3',
100
+ endType: 'count',
101
+ endValue: '8',
102
+ }}
103
+ />
104
+ );
105
+ expect(screen.getByLabelText('month')).toHaveValue(3);
106
+ fireEvent.click(screen.getByText('Save'));
107
+ expect(JSON.parse(screen.getByRole('status').textContent!)).toMatchObject({
108
+ frequency: 'yearly',
109
+ month: 3,
110
+ weekIndex: -1,
111
+ end: { type: 'count', count: 8 },
112
+ });
113
+ });
114
+ it('allows explicitly choosing month-end overflow', () => {
115
+ render(
116
+ <Form
117
+ initial={{
118
+ startLocal: '2026-03-31T09:00',
119
+ endLocal: '2026-03-31T10:00',
120
+ monthDay: '31',
121
+ }}
122
+ />
123
+ );
124
+ fireEvent.change(screen.getByLabelText('skip'), {
125
+ target: { value: 'last-day' },
126
+ });
127
+ fireEvent.click(screen.getByText('Save'));
128
+ expect(JSON.parse(screen.getByRole('status').textContent!)).toMatchObject({
129
+ monthDay: 31,
130
+ monthDayOverflow: 'last-day',
131
+ });
132
+ });
133
+ it('hides month controls for weekly series and updates selected weekdays', () => {
134
+ render(<Form initial={{ frequency: 'weekly' }} />);
135
+ expect(screen.queryByText('monthPattern')).not.toBeInTheDocument();
136
+ fireEvent.click(screen.getByLabelText('MO'));
137
+ fireEvent.click(screen.getByText('Save'));
138
+ expect(JSON.parse(screen.getByRole('status').textContent!)).toMatchObject({
139
+ weekdays: ['FR', 'MO'],
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+ import { useTranslations } from 'next-intl';
3
+ import { Checkbox } from '../../checkbox';
4
+ import { Input } from '../../input';
5
+ import { Label } from '../../label';
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '../../select';
13
+ import type { RecurrenceDraft } from './native-recurrence-model';
14
+
15
+ export function NativeRecurrencePatternFields({
16
+ draft,
17
+ setDraft,
18
+ }: {
19
+ draft: RecurrenceDraft;
20
+ setDraft: (draft: RecurrenceDraft) => void;
21
+ }) {
22
+ const t = useTranslations('calendar.recurrence');
23
+ const field = (key: keyof RecurrenceDraft, value: unknown) =>
24
+ setDraft({ ...draft, [key]: value });
25
+ const monthly = draft.frequency === 'monthly' || draft.frequency === 'yearly';
26
+ const relative = monthly && draft.monthPattern === 'weekday';
27
+ return (
28
+ <>
29
+ <div className="grid grid-cols-2 gap-3">
30
+ <div>
31
+ <Label>{t('frequency')}</Label>
32
+ <Select
33
+ value={draft.frequency}
34
+ onValueChange={(value) => field('frequency', value)}
35
+ >
36
+ <SelectTrigger aria-label={t('frequency')}>
37
+ <SelectValue />
38
+ </SelectTrigger>
39
+ <SelectContent>
40
+ {(['daily', 'weekly', 'monthly', 'yearly'] as const).map(
41
+ (value) => (
42
+ <SelectItem key={value} value={value}>
43
+ {t(value)}
44
+ </SelectItem>
45
+ )
46
+ )}
47
+ </SelectContent>
48
+ </Select>
49
+ </div>
50
+ <div>
51
+ <Label htmlFor="series-interval">{t('interval')}</Label>
52
+ <Input
53
+ id="series-interval"
54
+ type="number"
55
+ min={1}
56
+ max={1000}
57
+ value={draft.interval}
58
+ onChange={(e) => field('interval', e.target.value)}
59
+ />
60
+ </div>
61
+ </div>
62
+ {monthly && (
63
+ <div className="grid grid-cols-2 gap-3">
64
+ <div>
65
+ <Label>{t('monthPattern')}</Label>
66
+ <Select
67
+ value={draft.monthPattern}
68
+ onValueChange={(value) => field('monthPattern', value)}
69
+ >
70
+ <SelectTrigger aria-label={t('monthPattern')}>
71
+ <SelectValue />
72
+ </SelectTrigger>
73
+ <SelectContent>
74
+ <SelectItem value="day">{t('monthDayPattern')}</SelectItem>
75
+ <SelectItem value="weekday">
76
+ {t('monthWeekdayPattern')}
77
+ </SelectItem>
78
+ </SelectContent>
79
+ </Select>
80
+ </div>
81
+ {draft.frequency === 'yearly' && (
82
+ <div>
83
+ <Label htmlFor="series-month">{t('month')}</Label>
84
+ <Input
85
+ id="series-month"
86
+ type="number"
87
+ min={1}
88
+ max={12}
89
+ value={
90
+ draft.month ||
91
+ (draft.startLocal
92
+ ? String(Number(draft.startLocal.slice(5, 7)))
93
+ : '')
94
+ }
95
+ onChange={(e) => field('month', e.target.value)}
96
+ />
97
+ </div>
98
+ )}
99
+ </div>
100
+ )}
101
+ {monthly &&
102
+ (relative ? (
103
+ <div>
104
+ <Label>{t('weekIndex')}</Label>
105
+ <Select
106
+ value={draft.weekIndex}
107
+ onValueChange={(value) => field('weekIndex', value)}
108
+ >
109
+ <SelectTrigger aria-label={t('weekIndex')}>
110
+ <SelectValue />
111
+ </SelectTrigger>
112
+ <SelectContent>
113
+ {(['1', '2', '3', '4', '-1'] as const).map((value) => (
114
+ <SelectItem key={value} value={value}>
115
+ {t(value === '-1' ? 'ordinalLast' : `ordinal${value}`)}
116
+ </SelectItem>
117
+ ))}
118
+ </SelectContent>
119
+ </Select>
120
+ </div>
121
+ ) : (
122
+ <div className="grid grid-cols-2 gap-3">
123
+ <div>
124
+ <Label htmlFor="series-monthday">{t('monthDay')}</Label>
125
+ <Input
126
+ id="series-monthday"
127
+ type="number"
128
+ min={1}
129
+ max={31}
130
+ value={draft.monthDay}
131
+ onChange={(e) => field('monthDay', e.target.value)}
132
+ />
133
+ </div>
134
+ <div>
135
+ <Label>{t('monthDayOverflow')}</Label>
136
+ <Select
137
+ value={draft.monthDayOverflow ?? 'skip'}
138
+ onValueChange={(value) => field('monthDayOverflow', value)}
139
+ >
140
+ <SelectTrigger aria-label={t('monthDayOverflow')}>
141
+ <SelectValue />
142
+ </SelectTrigger>
143
+ <SelectContent>
144
+ <SelectItem value="skip">{t('monthDaySkip')}</SelectItem>
145
+ <SelectItem value="last-day">{t('monthDayLast')}</SelectItem>
146
+ </SelectContent>
147
+ </Select>
148
+ </div>
149
+ </div>
150
+ ))}
151
+ {(draft.frequency === 'weekly' || relative) && (
152
+ <fieldset aria-label={t('weekdays')} className="flex flex-wrap gap-2">
153
+ {(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const).map((day) => (
154
+ <label key={day} className="flex items-center gap-1">
155
+ <Checkbox
156
+ checked={draft.weekdays.includes(day)}
157
+ onCheckedChange={(checked) =>
158
+ field(
159
+ 'weekdays',
160
+ checked
161
+ ? [...draft.weekdays, day]
162
+ : draft.weekdays.filter((d) => d !== day)
163
+ )
164
+ }
165
+ />
166
+ {t(day)}
167
+ </label>
168
+ ))}
169
+ </fieldset>
170
+ )}
171
+ {relative && (
172
+ <p className="text-muted-foreground text-xs">
173
+ {t('relativeWeekdayHelp')}
174
+ </p>
175
+ )}
176
+ </>
177
+ );
178
+ }