@startsimpli/ui 0.4.13 → 0.4.15

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 (73) hide show
  1. package/README.md +457 -398
  2. package/package.json +20 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/calendar-view.test.tsx +97 -0
  5. package/src/components/__tests__/chat.test.tsx +129 -0
  6. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  7. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  8. package/src/components/__tests__/upcoming-meetings.test.tsx +104 -0
  9. package/src/components/__tests__/workspace.test.tsx +106 -0
  10. package/src/components/account/__tests__/account.test.tsx +5 -32
  11. package/src/components/account/change-password-form.tsx +1 -28
  12. package/src/components/calendar/calendar-view.tsx +253 -0
  13. package/src/components/calendar/index.ts +20 -0
  14. package/src/components/calendar/meetings-list.tsx +202 -0
  15. package/src/components/calendar/upcoming-meetings.tsx +211 -0
  16. package/src/components/chat/ChatComposer.tsx +113 -0
  17. package/src/components/chat/ChatMessage.tsx +81 -0
  18. package/src/components/chat/ChatThread.tsx +57 -0
  19. package/src/components/chat/index.ts +12 -0
  20. package/src/components/chat/types.ts +20 -0
  21. package/src/components/index.ts +16 -0
  22. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  23. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  24. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  25. package/src/components/slide-deck/index.ts +7 -0
  26. package/src/components/slide-deck/types.ts +18 -0
  27. package/src/components/team/DomainClaimCard.tsx +170 -0
  28. package/src/components/team/InviteMemberDialog.tsx +182 -0
  29. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  30. package/src/components/team/MembersTable.tsx +138 -0
  31. package/src/components/team/OrgSwitcher.tsx +68 -0
  32. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  33. package/src/components/team/RoleSelector.tsx +68 -0
  34. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  35. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  36. package/src/components/team/index.ts +57 -0
  37. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  38. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  39. package/src/components/team/members-table-default-class-names.ts +39 -0
  40. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  41. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  42. package/src/components/team/role-selector-default-class-names.ts +11 -0
  43. package/src/components/team/types.ts +97 -0
  44. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  45. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  46. package/src/components/workflows/NodeInspector.tsx +257 -0
  47. package/src/components/workflows/NodePalette.tsx +119 -0
  48. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  49. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  50. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  51. package/src/components/workflows/WorkflowNode.tsx +198 -0
  52. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  53. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  54. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  55. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  56. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  57. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  58. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  59. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  60. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  61. package/src/components/workflows/exec-status.ts +90 -0
  62. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  63. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  64. package/src/components/workflows/index.ts +78 -0
  65. package/src/components/workflows/layout/auto-layout.ts +142 -0
  66. package/src/components/workflows/node-icons.ts +31 -0
  67. package/src/components/workflows/serialization.ts +171 -0
  68. package/src/components/workflows/theme/categories.ts +96 -0
  69. package/src/components/workflows/types.ts +231 -0
  70. package/src/components/workflows/workflows.css +29 -0
  71. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  72. package/src/components/workspace/SplitPane.tsx +174 -0
  73. package/src/components/workspace/index.ts +4 -0
@@ -0,0 +1,106 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { SplitPane } from '../workspace/SplitPane'
3
+ import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace'
4
+
5
+ describe('SplitPane', () => {
6
+ it('renders both panes', () => {
7
+ render(<SplitPane first={<div>LEFT</div>} second={<div>RIGHT</div>} />)
8
+ expect(screen.getByText('LEFT')).toBeInTheDocument()
9
+ expect(screen.getByText('RIGHT')).toBeInTheDocument()
10
+ })
11
+
12
+ it('exposes an accessible resizer separator with aria values', () => {
13
+ render(
14
+ <SplitPane
15
+ first={<div>L</div>}
16
+ second={<div>R</div>}
17
+ initialSize={40}
18
+ minSize={20}
19
+ maxSize={80}
20
+ />
21
+ )
22
+ const sep = screen.getByRole('separator')
23
+ expect(sep).toHaveAttribute('aria-orientation', 'vertical')
24
+ expect(sep).toHaveAttribute('aria-valuenow', '40')
25
+ expect(sep).toHaveAttribute('aria-valuemin', '20')
26
+ expect(sep).toHaveAttribute('aria-valuemax', '80')
27
+ })
28
+
29
+ it('grows the first pane on ArrowRight and shrinks on ArrowLeft', () => {
30
+ const onResize = jest.fn()
31
+ render(
32
+ <SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={50} onResize={onResize} />
33
+ )
34
+ const sep = screen.getByRole('separator')
35
+ fireEvent.keyDown(sep, { key: 'ArrowRight' })
36
+ expect(Number(sep.getAttribute('aria-valuenow'))).toBeGreaterThan(50)
37
+ fireEvent.keyDown(sep, { key: 'ArrowLeft' })
38
+ fireEvent.keyDown(sep, { key: 'ArrowLeft' })
39
+ expect(Number(sep.getAttribute('aria-valuenow'))).toBeLessThan(50)
40
+ expect(onResize).toHaveBeenCalled()
41
+ })
42
+
43
+ it('clamps size to min/max', () => {
44
+ render(<SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={22} minSize={20} maxSize={80} />)
45
+ const sep = screen.getByRole('separator')
46
+ // hammer left many times — must not drop below min
47
+ for (let i = 0; i < 20; i++) fireEvent.keyDown(sep, { key: 'ArrowLeft' })
48
+ expect(Number(sep.getAttribute('aria-valuenow'))).toBe(20)
49
+ })
50
+
51
+ it('persists size to localStorage when storageKey is set', () => {
52
+ window.localStorage.clear()
53
+ render(<SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={50} storageKey="sp-test" />)
54
+ const sep = screen.getByRole('separator')
55
+ fireEvent.keyDown(sep, { key: 'ArrowRight' })
56
+ expect(window.localStorage.getItem('sp-test')).not.toBeNull()
57
+ })
58
+ })
59
+
60
+ describe('DualPaneWorkspace', () => {
61
+ it('renders left and right content with labels and an optional toolbar', () => {
62
+ render(
63
+ <DualPaneWorkspace
64
+ leftLabel="Chat"
65
+ rightLabel="Slides"
66
+ toolbar={<div>TOOLBAR</div>}
67
+ left={<div>CHAT_PANE</div>}
68
+ right={<div>SLIDES_PANE</div>}
69
+ layout="split"
70
+ />
71
+ )
72
+ expect(screen.getByText('CHAT_PANE')).toBeInTheDocument()
73
+ expect(screen.getByText('SLIDES_PANE')).toBeInTheDocument()
74
+ expect(screen.getByText('TOOLBAR')).toBeInTheDocument()
75
+ })
76
+
77
+ it('stacks panes when layout="stack"', () => {
78
+ const { container } = render(
79
+ <DualPaneWorkspace left={<div>L</div>} right={<div>R</div>} layout="stack" />
80
+ )
81
+ const root = container.querySelector('[data-layout="stack"]')
82
+ expect(root).toBeInTheDocument()
83
+ // both panes still rendered when stacked
84
+ expect(screen.getByText('L')).toBeInTheDocument()
85
+ expect(screen.getByText('R')).toBeInTheDocument()
86
+ })
87
+
88
+ it('collapses a pane when collapsible and the collapse control is used', () => {
89
+ render(
90
+ <DualPaneWorkspace
91
+ leftLabel="Chat"
92
+ rightLabel="Slides"
93
+ left={<div>CHAT_PANE</div>}
94
+ right={<div>SLIDES_PANE</div>}
95
+ layout="split"
96
+ collapsible
97
+ />
98
+ )
99
+ const collapseLeft = screen.getByRole('button', { name: /collapse chat/i })
100
+ fireEvent.click(collapseLeft)
101
+ expect(screen.queryByText('CHAT_PANE')).not.toBeInTheDocument()
102
+ // right still visible, and an expand affordance appears
103
+ expect(screen.getByText('SLIDES_PANE')).toBeInTheDocument()
104
+ expect(screen.getByRole('button', { name: /expand chat/i })).toBeInTheDocument()
105
+ })
106
+ })
@@ -148,11 +148,11 @@ describe('ChangePasswordForm', () => {
148
148
  jest.clearAllMocks()
149
149
  })
150
150
 
151
- it('renders all three password fields', () => {
151
+ it('renders both password fields (current + new, no confirm — startsim-nbq)', () => {
152
152
  render(<ChangePasswordForm {...defaultPasswordProps} />)
153
153
  expect(screen.getByLabelText('Current password')).toBeInTheDocument()
154
154
  expect(screen.getByLabelText('New password')).toBeInTheDocument()
155
- expect(screen.getByLabelText('Confirm new password')).toBeInTheDocument()
155
+ expect(screen.queryByLabelText('Confirm new password')).not.toBeInTheDocument()
156
156
  })
157
157
 
158
158
  it('submit button is disabled when fields are empty', () => {
@@ -165,7 +165,6 @@ describe('ChangePasswordForm', () => {
165
165
 
166
166
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
167
167
  changeInput(screen.getByLabelText('New password'), 'short')
168
- changeInput(screen.getByLabelText('Confirm new password'), 'short')
169
168
 
170
169
  expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled()
171
170
  })
@@ -182,40 +181,21 @@ describe('ChangePasswordForm', () => {
182
181
  expect(screen.queryByText('Must be at least 8 characters.')).not.toBeInTheDocument()
183
182
  })
184
183
 
185
- it('submit button is disabled when passwords do not match', () => {
184
+ it('submit button is enabled when current + new (>=8) are filled', () => {
186
185
  render(<ChangePasswordForm {...defaultPasswordProps} />)
187
186
 
188
187
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
189
188
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
190
- changeInput(screen.getByLabelText('Confirm new password'), 'different123')
191
-
192
- expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled()
193
- })
194
-
195
- it('shows mismatch hint when confirm does not match', () => {
196
- render(<ChangePasswordForm {...defaultPasswordProps} />)
197
- changeInput(screen.getByLabelText('New password'), 'newpassword1')
198
- changeInput(screen.getByLabelText('Confirm new password'), 'different123')
199
- expect(screen.getByText('Passwords do not match.')).toBeInTheDocument()
200
- })
201
-
202
- it('submit button is enabled when all fields are valid', () => {
203
- render(<ChangePasswordForm {...defaultPasswordProps} />)
204
-
205
- changeInput(screen.getByLabelText('Current password'), 'oldpassword')
206
- changeInput(screen.getByLabelText('New password'), 'newpassword1')
207
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
208
189
 
209
190
  expect(screen.getByRole('button', { name: /change password/i })).toBeEnabled()
210
191
  })
211
192
 
212
- it('calls onSubmit with correct payload', async () => {
193
+ it('calls onSubmit with { old_password, new_password } — no confirm', async () => {
213
194
  const onSubmit = jest.fn().mockResolvedValue(undefined)
214
195
  render(<ChangePasswordForm onSubmit={onSubmit} />)
215
196
 
216
197
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
217
198
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
218
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
219
199
 
220
200
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
221
201
 
@@ -223,7 +203,6 @@ describe('ChangePasswordForm', () => {
223
203
  expect(onSubmit).toHaveBeenCalledWith({
224
204
  old_password: 'oldpassword',
225
205
  new_password: 'newpassword1',
226
- new_password_confirm: 'newpassword1',
227
206
  })
228
207
  })
229
208
  })
@@ -234,7 +213,6 @@ describe('ChangePasswordForm', () => {
234
213
 
235
214
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
236
215
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
237
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
238
216
 
239
217
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
240
218
 
@@ -244,7 +222,6 @@ describe('ChangePasswordForm', () => {
244
222
 
245
223
  expect(screen.getByLabelText('Current password')).toHaveValue('')
246
224
  expect(screen.getByLabelText('New password')).toHaveValue('')
247
- expect(screen.getByLabelText('Confirm new password')).toHaveValue('')
248
225
  })
249
226
 
250
227
  it('calls onSuccess callback after successful submit', async () => {
@@ -254,7 +231,6 @@ describe('ChangePasswordForm', () => {
254
231
 
255
232
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
256
233
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
257
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
258
234
 
259
235
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
260
236
 
@@ -269,7 +245,6 @@ describe('ChangePasswordForm', () => {
269
245
 
270
246
  changeInput(screen.getByLabelText('Current password'), 'wrongold')
271
247
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
272
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
273
248
 
274
249
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
275
250
 
@@ -284,7 +259,6 @@ describe('ChangePasswordForm', () => {
284
259
 
285
260
  changeInput(screen.getByLabelText('Current password'), 'wrongold')
286
261
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
287
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
288
262
 
289
263
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
290
264
 
@@ -293,11 +267,10 @@ describe('ChangePasswordForm', () => {
293
267
  })
294
268
  })
295
269
 
296
- it('disables all inputs when disabled prop is true', () => {
270
+ it('disables both inputs when disabled prop is true', () => {
297
271
  render(<ChangePasswordForm {...defaultPasswordProps} disabled />)
298
272
  expect(screen.getByLabelText('Current password')).toBeDisabled()
299
273
  expect(screen.getByLabelText('New password')).toBeDisabled()
300
- expect(screen.getByLabelText('Confirm new password')).toBeDisabled()
301
274
  })
302
275
 
303
276
  it('renders card heading', () => {
@@ -11,7 +11,6 @@ export interface ChangePasswordFormProps {
11
11
  onSubmit: (values: {
12
12
  old_password: string
13
13
  new_password: string
14
- new_password_confirm: string
15
14
  }) => Promise<void>
16
15
  /** Called after a successful password change (e.g. to sign out) */
17
16
  onSuccess?: () => void
@@ -21,38 +20,27 @@ export interface ChangePasswordFormProps {
21
20
  export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePasswordFormProps) {
22
21
  const [oldPassword, setOldPassword] = useState('')
23
22
  const [newPassword, setNewPassword] = useState('')
24
- const [confirmPassword, setConfirmPassword] = useState('')
25
23
  const [saving, setSaving] = useState(false)
26
24
  const [error, setError] = useState<string | null>(null)
27
25
  const [success, setSuccess] = useState(false)
28
26
 
29
- const isValid =
30
- oldPassword.length > 0 &&
31
- newPassword.length >= 8 &&
32
- newPassword === confirmPassword
27
+ const isValid = oldPassword.length > 0 && newPassword.length >= 8
33
28
 
34
29
  const handleSubmit = async (e: React.FormEvent) => {
35
30
  e.preventDefault()
36
31
  setError(null)
37
32
  setSuccess(false)
38
33
 
39
- if (newPassword !== confirmPassword) {
40
- setError('New passwords do not match.')
41
- return
42
- }
43
-
44
34
  setSaving(true)
45
35
 
46
36
  try {
47
37
  await onSubmit({
48
38
  old_password: oldPassword,
49
39
  new_password: newPassword,
50
- new_password_confirm: confirmPassword,
51
40
  })
52
41
  setSuccess(true)
53
42
  setOldPassword('')
54
43
  setNewPassword('')
55
- setConfirmPassword('')
56
44
  onSuccess?.()
57
45
  } catch (err) {
58
46
  setError(err instanceof Error ? err.message : 'Failed to change password')
@@ -100,21 +88,6 @@ export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePass
100
88
  )}
101
89
  </div>
102
90
 
103
- <div className="space-y-2">
104
- <Label htmlFor="cp-confirm-password">Confirm new password</Label>
105
- <Input
106
- id="cp-confirm-password"
107
- type="password"
108
- value={confirmPassword}
109
- onChange={(e) => setConfirmPassword(e.target.value)}
110
- disabled={disabled || saving}
111
- autoComplete="new-password"
112
- />
113
- {confirmPassword.length > 0 && newPassword !== confirmPassword && (
114
- <p className="text-xs text-destructive">Passwords do not match.</p>
115
- )}
116
- </div>
117
-
118
91
  {error && (
119
92
  <p className="text-sm text-destructive">{error}</p>
120
93
  )}
@@ -0,0 +1,253 @@
1
+ "use client"
2
+
3
+ /**
4
+ * CalendarView — shared month/week/day event grid.
5
+ *
6
+ * Wraps react-big-calendar with a date-fns localizer pre-wired and a
7
+ * simplified event shape so consumers don't have to reach for raw RBC types.
8
+ *
9
+ * IMPORTANT: react-big-calendar ships its own CSS. Consumers must import
10
+ * `react-big-calendar/lib/css/react-big-calendar.css` once at the app level
11
+ * (e.g. in _app.tsx / layout.tsx). This package does NOT auto-inject CSS so
12
+ * consumers can layer Tailwind overrides predictably.
13
+ *
14
+ * lifecycle:calendar-ui (raise-simpli-k4u)
15
+ */
16
+
17
+ import * as React from "react"
18
+ import {
19
+ Calendar,
20
+ dateFnsLocalizer,
21
+ Views,
22
+ type View,
23
+ type SlotInfo,
24
+ } from "react-big-calendar"
25
+ import { format, parse, startOfWeek, getDay } from "date-fns"
26
+ import { enUS } from "date-fns/locale"
27
+
28
+ const locales = { "en-US": enUS }
29
+
30
+ const localizer = dateFnsLocalizer({
31
+ format,
32
+ parse,
33
+ startOfWeek: () => startOfWeek(new Date(), { weekStartsOn: 0 }),
34
+ getDay,
35
+ locales,
36
+ })
37
+
38
+ /** Domain-friendly event shape callers pass in. */
39
+ export interface CalendarEvent<T = unknown> {
40
+ /** Stable identifier — passed through to onEventClick payload */
41
+ id: string | number
42
+ /** Event title shown on the grid */
43
+ title: string
44
+ /** Event start (Date or ISO string) */
45
+ start: Date | string
46
+ /** Event end. If omitted, defaults to start + 30min */
47
+ end?: Date | string
48
+ /** Whether to render as all-day across the top of the day */
49
+ allDay?: boolean
50
+ /** Optional pass-through payload (the original ScheduledMeeting / etc.) */
51
+ resource?: T
52
+ }
53
+
54
+ interface InternalRBCEvent {
55
+ id: string | number
56
+ title: string
57
+ start: Date
58
+ end: Date
59
+ allDay: boolean
60
+ resource: unknown
61
+ }
62
+
63
+ const DEFAULT_DURATION_MS = 30 * 60 * 1000
64
+
65
+ function toDate(value: Date | string): Date {
66
+ return value instanceof Date ? value : new Date(value)
67
+ }
68
+
69
+ function normalizeEvent(e: CalendarEvent): InternalRBCEvent {
70
+ const start = toDate(e.start)
71
+ const end = e.end ? toDate(e.end) : new Date(start.getTime() + DEFAULT_DURATION_MS)
72
+ return {
73
+ id: e.id,
74
+ title: e.title,
75
+ start,
76
+ end,
77
+ allDay: e.allDay ?? false,
78
+ resource: e.resource,
79
+ }
80
+ }
81
+
82
+ export type CalendarViewMode = "month" | "week" | "day" | "agenda"
83
+
84
+ const VIEW_BY_MODE: Record<CalendarViewMode, View> = {
85
+ month: Views.MONTH,
86
+ week: Views.WEEK,
87
+ day: Views.DAY,
88
+ agenda: Views.AGENDA,
89
+ }
90
+
91
+ export interface CalendarViewProps<T = unknown> {
92
+ /** Events to render on the grid */
93
+ events: CalendarEvent<T>[]
94
+ /** Currently navigated date (controlled) */
95
+ date?: Date
96
+ /** Default date if uncontrolled */
97
+ defaultDate?: Date
98
+ /** Active view (controlled) */
99
+ view?: CalendarViewMode
100
+ /** Default view if uncontrolled */
101
+ defaultView?: CalendarViewMode
102
+ /** Allowed views the toolbar can switch between */
103
+ views?: CalendarViewMode[]
104
+ /** Fires when the user clicks an event. Receives the original event passed in. */
105
+ onEventClick?: (event: CalendarEvent<T>) => void
106
+ /** Fires when the user clicks an empty slot (or drags to select a range). */
107
+ onSlotClick?: (slot: { start: Date; end: Date; action: "click" | "select" }) => void
108
+ /** Fires when the user navigates to a new date. */
109
+ onNavigate?: (date: Date) => void
110
+ /** Fires when the user changes view. */
111
+ onViewChange?: (view: CalendarViewMode) => void
112
+ /** Allow drag-to-select empty time slots (defaults true). */
113
+ selectable?: boolean
114
+ /** Min height for the grid (default 600px). */
115
+ minHeight?: number
116
+ /** className passed to the wrapping div for Tailwind overrides. */
117
+ className?: string
118
+ /**
119
+ * Per-event styling. Receives the original CalendarEvent (with resource)
120
+ * and returns optional className / inline style applied to the rendered
121
+ * event block. Useful for color-coding by source (e.g. real Outlook event
122
+ * vs locally-scheduled meeting).
123
+ */
124
+ eventPropGetter?: (event: CalendarEvent<T>) => {
125
+ className?: string
126
+ style?: React.CSSProperties
127
+ }
128
+ /**
129
+ * When the month grid runs out of vertical space, react-big-calendar
130
+ * collapses the overflow into a "+N more" link. Set true (default) to
131
+ * open the day's full event list as a popover when that link is clicked.
132
+ */
133
+ popup?: boolean
134
+ }
135
+
136
+ /**
137
+ * Shared calendar grid. Pre-wired with date-fns localizer.
138
+ *
139
+ * @example
140
+ * import "react-big-calendar/lib/css/react-big-calendar.css"
141
+ * import { CalendarView } from "@startsimpli/ui"
142
+ *
143
+ * <CalendarView
144
+ * events={meetings.map(m => ({
145
+ * id: m.id,
146
+ * title: m.title,
147
+ * start: m.scheduled_at,
148
+ * end: addMinutes(new Date(m.scheduled_at), m.duration_minutes),
149
+ * resource: m,
150
+ * }))}
151
+ * onEventClick={e => openMeetingDialog(e.resource)}
152
+ * onSlotClick={s => openCreateMeetingDialog(s.start)}
153
+ * />
154
+ */
155
+ export function CalendarView<T = unknown>({
156
+ events,
157
+ date,
158
+ defaultDate,
159
+ view,
160
+ defaultView,
161
+ views,
162
+ onEventClick,
163
+ onSlotClick,
164
+ onNavigate,
165
+ onViewChange,
166
+ selectable = true,
167
+ minHeight = 600,
168
+ className,
169
+ eventPropGetter,
170
+ popup = true,
171
+ }: CalendarViewProps<T>) {
172
+ const normalized = React.useMemo(() => events.map(normalizeEvent), [events])
173
+
174
+ // Map our public CalendarEvent[] back from the internal event RBC hands us
175
+ // on click — keeps the consumer API clean (they only see what they passed).
176
+ const eventByInternalId = React.useMemo(() => {
177
+ const map = new Map<string | number, CalendarEvent<T>>()
178
+ for (const e of events) map.set(e.id, e)
179
+ return map
180
+ }, [events])
181
+
182
+ const handleSelectEvent = React.useCallback(
183
+ (rbcEvent: object) => {
184
+ const id = (rbcEvent as InternalRBCEvent).id
185
+ const original = eventByInternalId.get(id)
186
+ if (original && onEventClick) onEventClick(original)
187
+ },
188
+ [eventByInternalId, onEventClick],
189
+ )
190
+
191
+ const handleSelectSlot = React.useCallback(
192
+ (slot: SlotInfo) => {
193
+ if (!onSlotClick) return
194
+ onSlotClick({
195
+ start: slot.start as Date,
196
+ end: slot.end as Date,
197
+ action: slot.action === "select" ? "select" : "click",
198
+ })
199
+ },
200
+ [onSlotClick],
201
+ )
202
+
203
+ const handleViewChange = React.useCallback(
204
+ (v: View) => {
205
+ if (!onViewChange) return
206
+ const mode = (Object.entries(VIEW_BY_MODE).find(([, val]) => val === v)?.[0] ?? "month") as CalendarViewMode
207
+ onViewChange(mode)
208
+ },
209
+ [onViewChange],
210
+ )
211
+
212
+ const allowedViews = React.useMemo<View[]>(
213
+ () => (views ?? ["month", "week", "day", "agenda"]).map(v => VIEW_BY_MODE[v]),
214
+ [views],
215
+ )
216
+
217
+ const rbcEventPropGetter = React.useCallback(
218
+ (rbcEvent: object) => {
219
+ if (!eventPropGetter) return {}
220
+ const id = (rbcEvent as InternalRBCEvent).id
221
+ const original = eventByInternalId.get(id)
222
+ if (!original) return {}
223
+ return eventPropGetter(original)
224
+ },
225
+ [eventPropGetter, eventByInternalId],
226
+ )
227
+
228
+ return (
229
+ <div className={className} style={{ minHeight }}>
230
+ <Calendar
231
+ localizer={localizer}
232
+ events={normalized}
233
+ startAccessor="start"
234
+ endAccessor="end"
235
+ titleAccessor="title"
236
+ allDayAccessor="allDay"
237
+ date={date}
238
+ defaultDate={defaultDate}
239
+ view={view ? VIEW_BY_MODE[view] : undefined}
240
+ defaultView={defaultView ? VIEW_BY_MODE[defaultView] : undefined}
241
+ views={allowedViews}
242
+ onSelectEvent={handleSelectEvent}
243
+ onSelectSlot={handleSelectSlot}
244
+ onNavigate={onNavigate}
245
+ onView={handleViewChange}
246
+ selectable={selectable}
247
+ popup={popup}
248
+ eventPropGetter={eventPropGetter ? rbcEventPropGetter : undefined}
249
+ style={{ height: "100%", minHeight }}
250
+ />
251
+ </div>
252
+ )
253
+ }
@@ -0,0 +1,20 @@
1
+ export { CalendarView } from "./calendar-view"
2
+ export type {
3
+ CalendarEvent,
4
+ CalendarViewMode,
5
+ CalendarViewProps,
6
+ } from "./calendar-view"
7
+
8
+ export { UpcomingMeetings } from "./upcoming-meetings"
9
+ export type {
10
+ UpcomingMeeting,
11
+ UpcomingMeetingStatus,
12
+ UpcomingMeetingsProps,
13
+ } from "./upcoming-meetings"
14
+
15
+ export { MeetingsList } from "./meetings-list"
16
+ export type {
17
+ MeetingsListItem,
18
+ MeetingsListProps,
19
+ MeetingsListStatus,
20
+ } from "./meetings-list"