@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
@@ -34,6 +34,7 @@ import { formatLunarDay, getLunarDate } from '../../../../lib/lunar-calendar';
34
34
  import { Button } from '../../button';
35
35
  import { Popover, PopoverContent, PopoverTrigger } from '../../popover';
36
36
  import { calendarDraftDate } from './calendar-period';
37
+ import { pastEventTreatment } from './past-event-treatment';
37
38
  import { useCalendarSettings } from './settings/settings-context';
38
39
 
39
40
  dayjs.extend(utc);
@@ -61,6 +62,7 @@ function MonthEvent({
61
62
  zone?: string;
62
63
  onOpen: (id: string) => void;
63
64
  }) {
65
+ const { preservePastEventOpacity } = useCalendar();
64
66
  const t = useTranslations('calendar');
65
67
  const title = event.title || t('views.untitled_event');
66
68
  const allDay = isAllDayEvent(event);
@@ -72,8 +74,9 @@ function MonthEvent({
72
74
  title={title}
73
75
  style={calendarEventStyle(event)}
74
76
  className={cn(
75
- 'flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-[10px] leading-tight transition-colors hover:ring-1 hover:ring-current focus-visible:outline-2 focus-visible:outline-ring sm:px-1.5 sm:text-xs',
76
- allDay && 'font-medium'
77
+ 'relative flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-[10px] leading-tight transition-colors hover:ring-1 hover:ring-current focus-visible:outline-2 focus-visible:outline-ring sm:px-1.5 sm:text-xs',
78
+ allDay && 'font-medium',
79
+ pastEventTreatment(event, preservePastEventOpacity)
77
80
  )}
78
81
  >
79
82
  {continued ? (
@@ -0,0 +1,21 @@
1
+ import { Repeat } from '@tuturuuu/icons';
2
+ import { useTranslations } from 'next-intl';
3
+ import { ContextMenuContent, ContextMenuItem } from '../../context-menu';
4
+
5
+ export function NativeRecurrenceContextMenu({
6
+ onEdit,
7
+ readOnly,
8
+ }: {
9
+ onEdit: () => void;
10
+ readOnly: boolean;
11
+ }) {
12
+ const t = useTranslations('calendar.recurrence');
13
+ return (
14
+ <ContextMenuContent>
15
+ <ContextMenuItem disabled={readOnly} onSelect={onEdit}>
16
+ <Repeat className="size-4" />
17
+ {t('edit')}
18
+ </ContextMenuItem>
19
+ </ContextMenuContent>
20
+ );
21
+ }
@@ -0,0 +1,245 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ const mock = vi.hoisted(() => ({
7
+ create: vi.fn(),
8
+ read: vi.fn(),
9
+ mutate: vi.fn(),
10
+ actor: { actorId: 'actor', assertActive: vi.fn() },
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/calendar-series', () => ({
13
+ createNativeCalendarSeries: mock.create,
14
+ getNativeCalendarSeries: mock.read,
15
+ mutateNativeCalendarSeries: mock.mutate,
16
+ }));
17
+ vi.mock('../../../../hooks/use-workspace-visibility', () => ({
18
+ useWorkspaceActor: () => mock.actor,
19
+ }));
20
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
21
+
22
+ import { NativeRecurrenceDialog } from './native-recurrence-dialog';
23
+
24
+ const series = {
25
+ id: 'series',
26
+ ws_id: 'ws',
27
+ revision: 4,
28
+ workspace_calendar_id: null,
29
+ rule: {
30
+ version: 1,
31
+ frequency: 'daily',
32
+ interval: 1,
33
+ timeZone: 'UTC',
34
+ end: { type: 'never' },
35
+ },
36
+ anchor: {
37
+ startLocal: '2026-10-05T09:00:00',
38
+ endLocal: '2026-10-05T10:00:00',
39
+ allDay: false,
40
+ },
41
+ payload: { title: 'Daily' },
42
+ exceptions: [],
43
+ };
44
+ const occurrence = {
45
+ id: 'event',
46
+ ws_id: 'ws',
47
+ seriesId: 'series',
48
+ seriesRevision: 2,
49
+ originalStartLocal: '2026-10-06T09:00:00',
50
+ start_at: '2026-10-06T09:00:00Z',
51
+ end_at: '2026-10-06T10:00:00Z',
52
+ title: 'Occurrence',
53
+ };
54
+ function setup(
55
+ props: Partial<Parameters<typeof NativeRecurrenceDialog>[0]> = {}
56
+ ) {
57
+ const client = new QueryClient({
58
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
59
+ });
60
+ const close = vi.fn();
61
+ const view = render(
62
+ <QueryClientProvider client={client}>
63
+ <NativeRecurrenceDialog
64
+ wsId="ws"
65
+ timezone="UTC"
66
+ open
67
+ onOpenChange={close}
68
+ {...props}
69
+ />
70
+ </QueryClientProvider>
71
+ );
72
+ return { ...view, close, client };
73
+ }
74
+ beforeEach(() => {
75
+ vi.resetAllMocks();
76
+ HTMLElement.prototype.scrollIntoView = vi.fn();
77
+ vi.stubGlobal(
78
+ 'ResizeObserver',
79
+ class {
80
+ observe() {}
81
+ unobserve() {}
82
+ disconnect() {}
83
+ }
84
+ );
85
+ mock.actor.actorId = 'actor';
86
+ mock.create.mockResolvedValue(series);
87
+ mock.read.mockResolvedValue(series);
88
+ mock.mutate.mockResolvedValue(series);
89
+ });
90
+ describe('native recurrence form interactions', () => {
91
+ it('creates through the series API and closes only after success', async () => {
92
+ const { close } = setup();
93
+ fireEvent.change(screen.getByLabelText('title'), {
94
+ target: { value: 'Planning' },
95
+ });
96
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
97
+ await waitFor(() => expect(mock.create).toHaveBeenCalledTimes(1));
98
+ expect(mock.create.mock.calls[0]?.[0]).toBe('ws');
99
+ expect(mock.create.mock.calls[0]?.[1]).toMatchObject({
100
+ event: { title: 'Planning' },
101
+ rule: { version: 1, frequency: 'weekly', timeZone: 'UTC' },
102
+ requestId: expect.any(String),
103
+ });
104
+ await waitFor(() => expect(close).toHaveBeenCalledWith(false));
105
+ });
106
+ it('retains the draft and request ID after transport failure for an idempotent retry', async () => {
107
+ mock.create.mockRejectedValueOnce(new InternalApiError('failed', 503));
108
+ const { close } = setup();
109
+ fireEvent.change(screen.getByLabelText('title'), {
110
+ target: { value: 'Planning' },
111
+ });
112
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
113
+ await screen.findByRole('alert');
114
+ expect(close).not.toHaveBeenCalled();
115
+ expect(screen.getByLabelText('title')).toHaveValue('Planning');
116
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
117
+ await waitFor(() => expect(mock.create).toHaveBeenCalledTimes(2));
118
+ expect(mock.create.mock.calls[1]?.[1].requestId).toBe(
119
+ mock.create.mock.calls[0]?.[1].requestId
120
+ );
121
+ });
122
+ it('rejects an invalid form before sending a mutation', async () => {
123
+ setup();
124
+ fireEvent.change(screen.getByLabelText('title'), {
125
+ target: { value: 'Planning' },
126
+ });
127
+ fireEvent.change(screen.getByLabelText('interval'), {
128
+ target: { value: '0' },
129
+ });
130
+ fireEvent.submit(
131
+ screen.getByRole('button', { name: 'save' }).closest('form')!
132
+ );
133
+ expect(screen.getByRole('alert')).toHaveTextContent('invalid');
134
+ expect(mock.create).not.toHaveBeenCalled();
135
+ });
136
+ it('disables writes in a read-only calendar', () => {
137
+ setup({ readOnly: true });
138
+ expect(screen.getByRole('button', { name: 'save' })).toBeDisabled();
139
+ expect(screen.getByLabelText('title')).toBeDisabled();
140
+ expect(mock.create).not.toHaveBeenCalled();
141
+ });
142
+ it('fences writes when the verified account scope changed', () => {
143
+ mock.actor.assertActive.mockImplementation(() => {
144
+ throw new Error('actor changed');
145
+ });
146
+ setup();
147
+ fireEvent.change(screen.getByLabelText('title'), {
148
+ target: { value: 'Planning' },
149
+ });
150
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
151
+ expect(screen.getByRole('alert')).toHaveTextContent('invalid');
152
+ expect(mock.create).not.toHaveBeenCalled();
153
+ });
154
+ it('edits a virtual occurrence using the loaded series revision and immutable original slot', async () => {
155
+ setup({ occurrence });
156
+ const title = await screen.findByLabelText('title');
157
+ fireEvent.change(title, { target: { value: 'New title' } });
158
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
159
+ await waitFor(() => expect(mock.mutate).toHaveBeenCalledTimes(1));
160
+ expect(mock.mutate.mock.calls[0]?.slice(0, 2)).toEqual(['ws', 'series']);
161
+ expect(mock.mutate.mock.calls[0]?.[2]).toMatchObject({
162
+ scope: 'this',
163
+ expectedRevision: 4,
164
+ originalStartLocal: '2026-10-06T09:00:00',
165
+ event: { title: 'New title' },
166
+ });
167
+ expect(mock.mutate.mock.calls[0]?.[2].rule).toBeUndefined();
168
+ expect(mock.create).not.toHaveBeenCalled();
169
+ });
170
+ it.each(['all', 'future'] as const)(
171
+ 'saves the selected %s scope with its original slot and recurrence rule',
172
+ async (scope) => {
173
+ setup({ occurrence });
174
+ await screen.findByLabelText('title');
175
+ fireEvent.keyDown(screen.getByRole('combobox', { name: 'scope' }), {
176
+ key: 'ArrowDown',
177
+ });
178
+ expect(
179
+ screen.queryByText('futureExceptionsReset')
180
+ ).not.toBeInTheDocument();
181
+ fireEvent.click(await screen.findByRole('option', { name: scope }));
182
+ if (scope === 'future') {
183
+ expect(screen.getByText('futureExceptionsReset')).toBeVisible();
184
+ } else {
185
+ expect(
186
+ screen.queryByText('futureExceptionsReset')
187
+ ).not.toBeInTheDocument();
188
+ }
189
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
190
+ await waitFor(() => expect(mock.mutate).toHaveBeenCalledTimes(1));
191
+ expect(mock.mutate.mock.calls[0]?.[2]).toMatchObject({
192
+ scope,
193
+ expectedRevision: 4,
194
+ originalStartLocal: occurrence.originalStartLocal,
195
+ rule: { frequency: 'daily' },
196
+ anchor: {
197
+ startLocal:
198
+ scope === 'all'
199
+ ? series.anchor.startLocal
200
+ : occurrence.originalStartLocal,
201
+ },
202
+ });
203
+ }
204
+ );
205
+ it('omits the tail-reset notice when the first occurrence becomes a whole-series edit', async () => {
206
+ setup({
207
+ occurrence: {
208
+ ...occurrence,
209
+ originalStartLocal: series.anchor.startLocal,
210
+ },
211
+ });
212
+ await screen.findByLabelText('title');
213
+ fireEvent.keyDown(screen.getByRole('combobox', { name: 'scope' }), {
214
+ key: 'ArrowDown',
215
+ });
216
+ fireEvent.click(await screen.findByRole('option', { name: 'future' }));
217
+ expect(screen.queryByText('futureExceptionsReset')).not.toBeInTheDocument();
218
+ });
219
+ it('keeps permission failures visible without closing or pretending a save succeeded', async () => {
220
+ mock.mutate.mockRejectedValue(new InternalApiError('denied', 403));
221
+ const { close } = setup({ occurrence });
222
+ await screen.findByLabelText('title');
223
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
224
+ await screen.findByRole('alert');
225
+ expect(close).not.toHaveBeenCalled();
226
+ expect(screen.getByLabelText('title')).toHaveValue('Occurrence');
227
+ });
228
+ it('resets draft and retry identity when reopened in a different workspace', async () => {
229
+ const { rerender, client, close } = setup();
230
+ fireEvent.change(screen.getByLabelText('title'), {
231
+ target: { value: 'Private draft' },
232
+ });
233
+ rerender(
234
+ <QueryClientProvider client={client}>
235
+ <NativeRecurrenceDialog
236
+ wsId="other"
237
+ timezone="UTC"
238
+ open
239
+ onOpenChange={close}
240
+ />
241
+ </QueryClientProvider>
242
+ );
243
+ expect(screen.getByLabelText('title')).toHaveValue('');
244
+ });
245
+ });
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { getCalendarProviderSeriesCapabilities } from '@tuturuuu/internal-api/calendar-provider-series';
4
+ import { getNativeCalendarSeries } from '@tuturuuu/internal-api/calendar-series';
5
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
6
+ import { useTranslations } from 'next-intl';
7
+ import { useWorkspaceActor } from '../../../../hooks/use-workspace-visibility';
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '../../dialog';
15
+ import { NativeRecurrenceForm } from './native-recurrence-form';
16
+ export function NativeRecurrenceDialog({
17
+ wsId,
18
+ open,
19
+ onOpenChange,
20
+ occurrence,
21
+ timezone,
22
+ readOnly = false,
23
+ }: {
24
+ wsId: string;
25
+ open: boolean;
26
+ onOpenChange: (open: boolean) => void;
27
+ occurrence?: CalendarEvent;
28
+ timezone?: string;
29
+ readOnly?: boolean;
30
+ }) {
31
+ const actor = useWorkspaceActor();
32
+ const t = useTranslations('calendar.recurrence');
33
+ const series = useQuery({
34
+ queryKey: [
35
+ 'native-calendar-series',
36
+ wsId,
37
+ actor?.actorId,
38
+ occurrence?.seriesId,
39
+ ],
40
+ enabled: open && !!actor && !!occurrence?.seriesId,
41
+ queryFn: async ({ signal }) => {
42
+ actor!.assertActive();
43
+ const value = await getNativeCalendarSeries(wsId, occurrence!.seriesId!, {
44
+ signal,
45
+ });
46
+ actor!.assertActive();
47
+ return value;
48
+ },
49
+ retry: false,
50
+ });
51
+ const capabilities = useQuery({
52
+ queryKey: ['calendar-provider-capabilities', wsId, actor?.actorId],
53
+ enabled: open && !!actor && !readOnly,
54
+ queryFn: async ({ signal }) => {
55
+ actor!.assertActive();
56
+ const value = await getCalendarProviderSeriesCapabilities(wsId, {
57
+ signal,
58
+ });
59
+ actor!.assertActive();
60
+ return value;
61
+ },
62
+ retry: false,
63
+ staleTime: 0,
64
+ });
65
+ if (!open) return null;
66
+ const known = !!actor && (!occurrence || (!!series.data && !series.isError));
67
+ return (
68
+ <Dialog open={open} onOpenChange={onOpenChange}>
69
+ <DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
70
+ <DialogHeader>
71
+ <DialogTitle>{t(occurrence ? 'edit' : 'create')}</DialogTitle>
72
+ <DialogDescription>{t('description')}</DialogDescription>
73
+ </DialogHeader>
74
+ {known ? (
75
+ <NativeRecurrenceForm
76
+ key={`${actor!.actorId}:${wsId}:${occurrence?.id ?? 'new'}:${series.data?.revision ?? 0}`}
77
+ wsId={wsId}
78
+ actor={actor!}
79
+ series={series.data}
80
+ occurrence={occurrence}
81
+ timezone={timezone}
82
+ readOnly={readOnly}
83
+ capabilities={capabilities.data}
84
+ onDone={() => onOpenChange(false)}
85
+ onReload={() => void series.refetch()}
86
+ />
87
+ ) : (
88
+ <p role="status">
89
+ {t(series.isPending && actor ? 'loading' : 'unavailable')}
90
+ </p>
91
+ )}
92
+ </DialogContent>
93
+ </Dialog>
94
+ );
95
+ }
@@ -0,0 +1,137 @@
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 {
14
+ type RecurrenceDraft,
15
+ recurrenceAllDay,
16
+ } from './native-recurrence-model';
17
+ import { NativeRecurrencePatternFields } from './native-recurrence-pattern-fields';
18
+ export function NativeRecurrenceFields({
19
+ draft,
20
+ setDraft,
21
+ occurrenceOnly,
22
+ disabled,
23
+ nativeStorageHint = true,
24
+ }: {
25
+ draft: RecurrenceDraft;
26
+ setDraft: (draft: RecurrenceDraft) => void;
27
+ occurrenceOnly: boolean;
28
+ disabled: boolean;
29
+ nativeStorageHint?: boolean;
30
+ }) {
31
+ const t = useTranslations('calendar.recurrence');
32
+ const field = (key: keyof RecurrenceDraft, value: unknown) =>
33
+ setDraft({ ...draft, [key]: value });
34
+ return (
35
+ <fieldset disabled={disabled} className="space-y-3">
36
+ <div className="space-y-1">
37
+ <Label htmlFor="series-title">{t('title')}</Label>
38
+ <Input
39
+ id="series-title"
40
+ value={draft.title}
41
+ maxLength={1000}
42
+ onChange={(e) => field('title', e.target.value)}
43
+ required
44
+ />
45
+ </div>
46
+ <div className="grid grid-cols-2 gap-3">
47
+ {(['startLocal', 'endLocal'] as const).map((key) => (
48
+ <div key={key} className="space-y-1">
49
+ <Label htmlFor={key}>
50
+ {t(key === 'startLocal' ? 'start' : 'end')}
51
+ </Label>
52
+ <Input
53
+ id={key}
54
+ type="datetime-local"
55
+ value={draft[key]}
56
+ onChange={(e) => field(key, e.target.value)}
57
+ required
58
+ />
59
+ </div>
60
+ ))}
61
+ </div>
62
+ <div className="space-y-1">
63
+ <Label htmlFor="series-timezone">{t('timezone')}</Label>
64
+ <Input
65
+ id="series-timezone"
66
+ value={draft.timeZone}
67
+ onChange={(e) => field('timeZone', e.target.value)}
68
+ disabled={occurrenceOnly || !!draft.retainedRule}
69
+ required
70
+ />
71
+ </div>
72
+ <label className="flex items-center gap-2">
73
+ <Checkbox
74
+ checked={draft.allDay}
75
+ disabled={occurrenceOnly}
76
+ onCheckedChange={(value) =>
77
+ setDraft(recurrenceAllDay(draft, value === true))
78
+ }
79
+ />
80
+ {t('allDay')}
81
+ </label>
82
+ {draft.retainedRule && !occurrenceOnly && (
83
+ <p className="text-muted-foreground text-xs">{t('advancedPattern')}</p>
84
+ )}
85
+ {!occurrenceOnly && !draft.retainedRule && (
86
+ <>
87
+ <NativeRecurrencePatternFields draft={draft} setDraft={setDraft} />
88
+ <div className="grid grid-cols-2 gap-3">
89
+ <div>
90
+ <Label>{t('ending')}</Label>
91
+ <Select
92
+ value={draft.endType}
93
+ onValueChange={(value) => field('endType', value)}
94
+ >
95
+ <SelectTrigger aria-label={t('ending')}>
96
+ <SelectValue />
97
+ </SelectTrigger>
98
+ <SelectContent>
99
+ {(['never', 'count', 'until'] as const).map((value) => (
100
+ <SelectItem value={value} key={value}>
101
+ {t(value)}
102
+ </SelectItem>
103
+ ))}
104
+ </SelectContent>
105
+ </Select>
106
+ </div>
107
+ {draft.endType !== 'never' && (
108
+ <div>
109
+ <Label htmlFor="series-endvalue">{t(draft.endType)}</Label>
110
+ <Input
111
+ id="series-endvalue"
112
+ type={draft.endType === 'count' ? 'number' : 'date'}
113
+ min={draft.endType === 'count' ? 1 : undefined}
114
+ max={draft.endType === 'count' ? 10000 : undefined}
115
+ value={draft.endValue}
116
+ onChange={(e) => field('endValue', e.target.value)}
117
+ required
118
+ />
119
+ </div>
120
+ )}
121
+ </div>
122
+ </>
123
+ )}
124
+ <div>
125
+ <Label htmlFor="series-location">{t('location')}</Label>
126
+ <Input
127
+ id="series-location"
128
+ value={draft.location}
129
+ onChange={(e) => field('location', e.target.value)}
130
+ />
131
+ </div>
132
+ {nativeStorageHint && (
133
+ <p className="text-muted-foreground text-xs">{t('nativeOnly')}</p>
134
+ )}
135
+ </fieldset>
136
+ );
137
+ }