@fayz-ai/plugin-agenda 0.1.8 → 0.2.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 (41) hide show
  1. package/dist/components/AgendaMobileList.d.ts +10 -0
  2. package/dist/components/AgendaMobileList.d.ts.map +1 -0
  3. package/dist/components/AppointmentModal.d.ts +4 -2
  4. package/dist/components/AppointmentModal.d.ts.map +1 -1
  5. package/dist/components/AppointmentPopover.d.ts.map +1 -1
  6. package/dist/components/EventContextMenu.d.ts.map +1 -1
  7. package/dist/config.d.ts +62 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/data/index.d.ts +1 -0
  10. package/dist/data/index.d.ts.map +1 -1
  11. package/dist/data/mock.d.ts +15 -1
  12. package/dist/data/mock.d.ts.map +1 -1
  13. package/dist/data/supabase.d.ts.map +1 -1
  14. package/dist/index.cjs +614 -308
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +452 -147
  19. package/dist/index.js.map +1 -1
  20. package/dist/locales/en.d.ts.map +1 -1
  21. package/dist/locales/pt-BR.d.ts.map +1 -1
  22. package/dist/store.d.ts +3 -0
  23. package/dist/store.d.ts.map +1 -1
  24. package/dist/types.d.ts +8 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/views/CalendarView.d.ts.map +1 -1
  27. package/package.json +5 -8
  28. package/src/components/AgendaMobileList.tsx +103 -0
  29. package/src/components/AppointmentModal.tsx +86 -9
  30. package/src/components/AppointmentPopover.tsx +20 -10
  31. package/src/components/EventContextMenu.tsx +3 -0
  32. package/src/config.ts +104 -5
  33. package/src/data/index.ts +1 -0
  34. package/src/data/mock.ts +22 -5
  35. package/src/data/supabase.ts +13 -2
  36. package/src/index.ts +8 -3
  37. package/src/locales/en.ts +12 -0
  38. package/src/locales/pt-BR.ts +12 -0
  39. package/src/store.ts +4 -2
  40. package/src/types.ts +8 -0
  41. package/src/views/CalendarView.tsx +220 -54
@@ -19,6 +19,7 @@ import { AppointmentModal } from '../components/AppointmentModal'
19
19
  import { AppointmentPopover } from '../components/AppointmentPopover'
20
20
  import { PersonLink } from '@fayz-ai/saas'
21
21
  import { EventContextMenu } from '../components/EventContextMenu'
22
+ import { AgendaMobileList } from '../components/AgendaMobileList'
22
23
  import { ConfirmDialog } from '@fayz-ai/ui'
23
24
  import { Button } from '@fayz-ai/ui'
24
25
  import type { CalendarBooking, Schedule } from '../types'
@@ -224,12 +225,55 @@ function CalendarSkeleton({ profCount = 2 }: { profCount?: number }) {
224
225
  )
225
226
  }
226
227
 
227
- const VIEW_OPTIONS = [
228
+ // Resource views — one column per professional. Used when the app has staff.
229
+ const RESOURCE_VIEW_OPTIONS = [
228
230
  { key: 'resourceTimeGridDay', labelKey: 'agenda.calendar.viewDay' },
229
231
  { key: 'resourceTimeGridWeek', labelKey: 'agenda.calendar.viewWeek' },
230
232
  { key: 'dayGridMonth', labelKey: 'agenda.calendar.viewMonth' },
231
233
  ] as const
232
234
 
235
+ // Non-resource variants — used when there are no professionals/resources (e.g. a
236
+ // personal-finance agenda). A resource timegrid renders an EMPTY grid with zero
237
+ // resource columns and drops events that have no resourceId, so those apps fall
238
+ // back to a plain month/day/week calendar.
239
+ const PLAIN_VIEW_OPTIONS = [
240
+ { key: 'timeGridDay', labelKey: 'agenda.calendar.viewDay' },
241
+ { key: 'timeGridWeek', labelKey: 'agenda.calendar.viewWeek' },
242
+ { key: 'dayGridMonth', labelKey: 'agenda.calendar.viewMonth' },
243
+ { key: 'listMonth', labelKey: 'agenda.calendar.viewList' },
244
+ ] as const
245
+
246
+ // Sentinel view key for the thumb-friendly mobile agenda list (AgendaMobileList),
247
+ // which is NOT a FullCalendar view. Selected by default on <md so B2C opens on the
248
+ // list; picking any grid option (Dia/Semana/Mês) mounts the real calendar instead.
249
+ const MOBILE_LIST_VIEW = '__mobileList__'
250
+
251
+ /** Collapse a resource view to a plain month calendar when there are no resources. */
252
+ function viewForNoResources(view: string): string {
253
+ return view.startsWith('resource') ? 'dayGridMonth' : view
254
+ }
255
+
256
+ /** Promote a plain timegrid to its resource variant when the app has resources. */
257
+ function viewForResources(view: string): string {
258
+ if (view === 'timeGridWeek') return 'resourceTimeGridWeek'
259
+ if (view === 'timeGridDay') return 'resourceTimeGridDay'
260
+ return view
261
+ }
262
+
263
+ /** Small viewport (<md) → render the thumb-friendly agenda list instead of the grid. */
264
+ function useIsMobile(): boolean {
265
+ const [isMobile, setIsMobile] = useState(() =>
266
+ typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches)
267
+ useEffect(() => {
268
+ if (typeof window === 'undefined') return
269
+ const mq = window.matchMedia('(max-width: 767px)')
270
+ const onChange = () => setIsMobile(mq.matches)
271
+ mq.addEventListener('change', onChange)
272
+ return () => mq.removeEventListener('change', onChange)
273
+ }, [])
274
+ return isMobile
275
+ }
276
+
233
277
  // ---------------------------------------------------------------------------
234
278
  // Calendar View
235
279
  // ---------------------------------------------------------------------------
@@ -268,6 +312,28 @@ export function CalendarView() {
268
312
  const closeModal = useAgendaStore((s) => s.closeAppointmentModal)
269
313
  const modalState = useAgendaStore((s) => s.appointmentModal)
270
314
 
315
+ // Resource-capable = the app has professionals/staff. When it doesn't (e.g. a
316
+ // personal-finance agenda), resource views render empty, so we force plain
317
+ // (non-resource) views and default to a month calendar.
318
+ const isMobile = useIsMobile()
319
+ const resourceCapable = professionals.length > 0
320
+ const viewOptions = resourceCapable ? RESOURCE_VIEW_OPTIONS : PLAIN_VIEW_OPTIONS
321
+
322
+ // Mobile (<md) has its own view selection, independent of the desktop pref: it
323
+ // defaults to the thumb-friendly list and lets the user switch to any grid view
324
+ // (Dia/Semana/Mês) without changing the desktop's stored calendarView. The list
325
+ // sentinel replaces FullCalendar's own listMonth so mobile always gets the rich
326
+ // AgendaMobileList, and a list option is offered even for resource apps.
327
+ const [mobileView, setMobileView] = useState<string>(MOBILE_LIST_VIEW)
328
+ const mobileViewOptions = useMemo(
329
+ () => [
330
+ ...viewOptions.filter((o) => o.key !== 'listMonth'),
331
+ { key: MOBILE_LIST_VIEW, labelKey: 'agenda.calendar.viewList' },
332
+ ],
333
+ [viewOptions],
334
+ )
335
+ const mobileGridActive = isMobile && mobileView !== MOBILE_LIST_VIEW
336
+
271
337
  const [selectedBooking, setSelectedBooking] = useState<CalendarBooking | null>(null)
272
338
  const [popoverAnchor, setPopoverAnchor] = useState<{ x: number; y: number } | null>(null)
273
339
  const [popoverVisible, _setPopoverVisible] = useState(false)
@@ -302,6 +368,30 @@ export function CalendarView() {
302
368
  if (config.modules.locationSelection) fetchLocations()
303
369
  }, [])
304
370
 
371
+ // Keep the view consistent with resource-capability, in case a stale/shared
372
+ // pref selected the wrong variant. No resources → a resource view is an empty
373
+ // grid that drops the seeded events; resources → a plain timegrid loses the
374
+ // per-professional columns.
375
+ useEffect(() => {
376
+ const api = calendarRef.current?.getApi()
377
+ if (!api) return
378
+ if (!resourceCapable && currentView.startsWith('resource')) {
379
+ api.changeView('dayGridMonth')
380
+ } else if (resourceCapable && (currentView === 'timeGridWeek' || currentView === 'timeGridDay')) {
381
+ api.changeView(viewForResources(currentView))
382
+ }
383
+ }, [resourceCapable, currentView])
384
+
385
+ // Mobile renders the agenda list (no FullCalendar), so the grid's datesSet —
386
+ // which normally drives fetchBookings — never fires. Fetch a wide upcoming
387
+ // window directly so the list has data.
388
+ useEffect(() => {
389
+ if (!isMobile) return
390
+ const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - 1)
391
+ const end = new Date(start); end.setDate(end.getDate() + 91)
392
+ fetchBookings({ start: start.toISOString(), end: end.toISOString() })
393
+ }, [isMobile, fetchBookings])
394
+
305
395
  const resources = useMemo(() => {
306
396
  const filtered = selectedProfIds.length > 0
307
397
  ? professionals.filter((p) => selectedProfIds.includes(p.id))
@@ -335,6 +425,9 @@ export function CalendarView() {
335
425
  const events = useMemo(() =>
336
426
  [...bookingEvents, ...availabilityEvents], [bookingEvents, availabilityEvents])
337
427
 
428
+ const storedView = prefs.get('calendarView')
429
+ const effectiveInitialView = resourceCapable ? viewForResources(storedView) : viewForNoResources(storedView)
430
+
338
431
  // ── Calendar handlers ──
339
432
  const handleDatesSet = useCallback((arg: { startStr: string; endStr: string; view: { title: string; type: string } }) => {
340
433
  setCalendarTitle(arg.view.title)
@@ -469,14 +562,16 @@ export function CalendarView() {
469
562
  openModal('create', { prefill: { startsAt: arg.startStr, endsAt: arg.endStr, professionalId, locationId: locId || undefined } })
470
563
  }
471
564
 
472
- // Warn if no schedule covers this slot
473
- if (!schedule) {
565
+ // Warn if no schedule covers this slot — only when the working-hours module
566
+ // is on (off by default in eventMode:'simple', so simple/Google-Calendar
567
+ // events never surface the "no open schedule for this professional" dialog).
568
+ if (config.modules.workingHours && !schedule) {
474
569
  setScheduleConfirm({ onConfirm: proceed })
475
570
  return
476
571
  }
477
572
 
478
573
  proceed()
479
- }, [openModal, findScheduleForSlot])
574
+ }, [openModal, findScheduleForSlot, config.modules.workingHours])
480
575
 
481
576
  const handleEventDrop = useCallback(async (arg: EventDropArg) => {
482
577
  const booking = arg.event.extendedProps.booking as CalendarBooking
@@ -488,6 +583,16 @@ export function CalendarView() {
488
583
  calendarRef.current?.getApi().changeView(v)
489
584
  prefs.set('calendarView', v)
490
585
  }, [prefs])
586
+ // Mobile switcher: swap between the list sentinel and a real grid view. When a
587
+ // grid is picked we also sync the store's currentView so the resource-collapse
588
+ // effect doesn't fight the freshly-mounted mobile calendar; if it's already
589
+ // mounted (grid → grid), changeView animates in place.
590
+ const handleMobileViewChange = useCallback((v: string) => {
591
+ setMobileView(v)
592
+ if (v === MOBILE_LIST_VIEW) return
593
+ setView(v)
594
+ calendarRef.current?.getApi()?.changeView(v)
595
+ }, [setView])
491
596
  const handlePrev = useCallback(() => calendarRef.current?.getApi().prev(), [])
492
597
  const handleNext = useCallback(() => calendarRef.current?.getApi().next(), [])
493
598
  const handleToday = useCallback(() => { calendarRef.current?.getApi().today(); setSelectedDate(new Date()) }, [])
@@ -587,8 +692,23 @@ export function CalendarView() {
587
692
  // Listen for cross-plugin booking open requests (e.g., from financial invoice detail)
588
693
  useEffect(() => {
589
694
  function handleOpenBooking(e: Event) {
590
- const bookingId = (e as CustomEvent).detail?.bookingId
591
- if (bookingId) openModal('edit', { bookingId })
695
+ const detail = (e as CustomEvent).detail ?? {}
696
+ const externalSource = detail.source
697
+ ? { type: String(detail.source), id: detail.waitlistId, waitlistId: detail.waitlistId }
698
+ : undefined
699
+ if (detail.bookingId) {
700
+ openModal('edit', { bookingId: detail.bookingId, externalSource })
701
+ return
702
+ }
703
+ openModal('create', {
704
+ prefill: detail.prefill ?? {
705
+ clientId: detail.clientId,
706
+ professionalId: detail.professionalId,
707
+ locationId: detail.locationId,
708
+ startsAt: detail.startsAt,
709
+ },
710
+ externalSource,
711
+ })
592
712
  }
593
713
  window.addEventListener('agenda:open-booking', handleOpenBooking)
594
714
  return () => window.removeEventListener('agenda:open-booking', handleOpenBooking)
@@ -684,9 +804,11 @@ export function CalendarView() {
684
804
  {/* Mini month calendar */}
685
805
  <MiniCalendar selectedDate={selectedDate} onDateSelect={handleMiniDateSelect} />
686
806
 
807
+ {/* Agendas (professional filter) — hidden when no booking type uses a
808
+ professional (e.g. eventMode:'simple'). */}
809
+ {config.capabilities.professional && (<>
687
810
  <div className="h-px bg-border my-4" />
688
811
 
689
- {/* Agendas */}
690
812
  <button onClick={() => setProfSectionOpen((p) => !p)}
691
813
  className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors">
692
814
  <span>Agendas</span>
@@ -710,10 +832,13 @@ export function CalendarView() {
710
832
  })}
711
833
  </div>
712
834
  )}
835
+ </>)}
713
836
 
837
+ {/* Status filter — hidden when no booking type uses a status workflow
838
+ (e.g. eventMode:'simple'). */}
839
+ {config.capabilities.status && (<>
714
840
  <div className="h-px bg-border my-4" />
715
841
 
716
- {/* Status */}
717
842
  <button onClick={() => setStatusSectionOpen((p) => !p)}
718
843
  className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors">
719
844
  <span>Status</span>
@@ -736,70 +861,105 @@ export function CalendarView() {
736
861
  })}
737
862
  </div>
738
863
  )}
864
+ </>)}
739
865
  </div>
740
866
 
741
867
  {/* ═══ MAIN AREA ═══ */}
742
868
  <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
743
869
  {/* Toolbar */}
744
- <div className="flex items-center justify-between px-4 h-12 shrink-0 border-b">
745
- <div className="flex items-center gap-3">
746
- {/* Mobile create button */}
747
- <button onClick={() => openModal('create')}
748
- className="md:hidden flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
749
- <Plus className="h-4 w-4" />
750
- </button>
751
-
752
- <button onClick={handleToday}
753
- className="rounded-md border px-3 py-1 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors">
754
- {t('agenda.toolbar.today')}
755
- </button>
756
- <div className="flex items-center gap-0.5">
757
- <button onClick={handlePrev} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
758
- <ChevronLeft className="h-4 w-4" />
870
+ <div className="shrink-0 border-b">
871
+ {/* Row 1 — create + date nav + title (left), desktop switcher + settings
872
+ (right). Extra right padding on mobile clears the transparent header's
873
+ floating profile avatar, which otherwise overlaps the settings gear. */}
874
+ <div className="flex items-center justify-between pl-4 pr-14 md:px-4 h-12">
875
+ <div className="flex items-center gap-3 min-w-0">
876
+ {/* Mobile create button */}
877
+ <button onClick={() => openModal('create')}
878
+ className="md:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
879
+ <Plus className="h-4 w-4" />
759
880
  </button>
760
- <button onClick={handleNext} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
761
- <ChevronRight className="h-4 w-4" />
762
- </button>
763
- </div>
764
- <h1 className="text-base sm:text-lg font-normal truncate">{calendarTitle}</h1>
765
- </div>
766
881
 
767
- <div className="flex items-center gap-1">
768
- <div className="hidden sm:flex items-center gap-1">
769
- {VIEW_OPTIONS.map((opt) => (
770
- <button key={opt.key} onClick={() => handleViewChange(opt.key)}
771
- className={`rounded-md px-3 py-1 text-sm transition-colors ${
772
- currentView === opt.key
773
- ? 'bg-primary/10 text-primary font-medium'
774
- : 'text-muted-foreground hover:bg-muted/50'
775
- }`}>
776
- {t(opt.labelKey)}
882
+ {/* Date navigation drives the FullCalendar api — shown on desktop and,
883
+ on mobile, only when a grid view is active (the list paginates
884
+ itself, so it has no grid to page through). */}
885
+ <div className={`${mobileGridActive ? 'flex' : 'hidden'} md:flex items-center gap-3`}>
886
+ <button onClick={handleToday}
887
+ className="rounded-md border px-3 py-1 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors">
888
+ {t('agenda.toolbar.today')}
777
889
  </button>
778
- ))}
890
+ <div className="flex items-center gap-0.5">
891
+ <button onClick={handlePrev} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
892
+ <ChevronLeft className="h-4 w-4" />
893
+ </button>
894
+ <button onClick={handleNext} className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
895
+ <ChevronRight className="h-4 w-4" />
896
+ </button>
897
+ </div>
898
+ </div>
899
+ <h1 className="text-base sm:text-lg font-normal truncate">{isMobile && !mobileGridActive ? t('agenda.calendar.viewAgenda') : calendarTitle}</h1>
900
+ </div>
901
+
902
+ <div className="flex items-center gap-1 shrink-0">
903
+ <div className="hidden md:flex items-center gap-1">
904
+ {viewOptions.map((opt) => (
905
+ <button key={opt.key} onClick={() => handleViewChange(opt.key)}
906
+ className={`rounded-md px-3 py-1 text-sm transition-colors ${
907
+ currentView === opt.key
908
+ ? 'bg-primary/10 text-primary font-medium'
909
+ : 'text-muted-foreground hover:bg-muted/50'
910
+ }`}>
911
+ {t(opt.labelKey)}
912
+ </button>
913
+ ))}
914
+ </div>
915
+ <Button
916
+ variant="outline"
917
+ size="icon"
918
+ onClick={() => window.location.hash = '/settings/agenda'}
919
+ title="Agenda Settings"
920
+ className="ml-1"
921
+ >
922
+ <Settings className="h-4 w-4" />
923
+ </Button>
779
924
  </div>
780
- <Button
781
- variant="outline"
782
- size="icon"
783
- onClick={() => window.location.hash = '/settings/agenda'}
784
- title="Agenda Settings"
785
- className="ml-1"
786
- >
787
- <Settings className="h-4 w-4" />
788
- </Button>
925
+ </div>
926
+
927
+ {/* Row 2 (mobile only) — compact segmented view switcher on its own row so
928
+ it never collides with the floating avatar. Dia/Semana/Mês mount the
929
+ calendar; Lista returns to the thumb-friendly agenda list. */}
930
+ <div className="flex md:hidden items-center gap-1 overflow-x-auto px-3 pb-2">
931
+ {mobileViewOptions.map((opt) => (
932
+ <button key={opt.key} onClick={() => handleMobileViewChange(opt.key)}
933
+ className={`shrink-0 rounded-md px-3 py-1 text-sm transition-colors ${
934
+ mobileView === opt.key
935
+ ? 'bg-primary/10 text-primary font-medium'
936
+ : 'text-muted-foreground hover:bg-muted/50'
937
+ }`}>
938
+ {t(opt.labelKey)}
939
+ </button>
940
+ ))}
789
941
  </div>
790
942
  </div>
791
943
 
792
- {/* Calendar */}
793
- {professionalsLoading && professionals.length === 0 ? (
944
+ {/* Mobile list view → thumb-friendly agenda list; otherwise (desktop, or a
945
+ mobile grid view the user switched to) → FullCalendar grid. */}
946
+ {isMobile && !mobileGridActive ? (
947
+ <AgendaMobileList
948
+ bookings={bookings}
949
+ statusColors={statusColors}
950
+ currencyCode={config.currency.code}
951
+ onSelect={(b) => openModal('edit', { bookingId: b.id })}
952
+ />
953
+ ) : professionalsLoading && professionals.length === 0 ? (
794
954
  <CalendarSkeleton profCount={2} />
795
955
  ) : (
796
956
  <div ref={wrapperRef} className="flex-1 overflow-hidden agenda-calendar">
797
957
  <FullCalendar
798
958
  ref={calendarRef}
799
959
  plugins={[dayGridPlugin, timeGridPlugin, resourceTimeGridPlugin, interactionPlugin, listPlugin]}
800
- initialView={prefs.get('calendarView') === 'timeGridWeek' ? 'resourceTimeGridWeek' : prefs.get('calendarView')}
960
+ initialView={mobileGridActive ? mobileView : effectiveInitialView}
801
961
  schedulerLicenseKey="CC-Attribution-NonCommercial-NoDerivatives"
802
- resources={resources}
962
+ resources={resourceCapable ? resources : undefined}
803
963
  events={events}
804
964
  headerToolbar={false}
805
965
  locale={fcLocale}
@@ -878,8 +1038,14 @@ export function CalendarView() {
878
1038
  bookingId={modalState.bookingId}
879
1039
  prefill={modalState.prefill}
880
1040
  initialTab={modalState.initialTab}
1041
+ externalSource={modalState.externalSource}
881
1042
  onClose={closeModal}
882
- onCreated={(newId) => {
1043
+ onCreated={(newId, source) => {
1044
+ if (config.onBookingCreated) {
1045
+ void Promise.resolve(config.onBookingCreated({ bookingId: newId, source })).catch((error: unknown) => {
1046
+ console.error('Failed to run agenda booking-created bridge', error)
1047
+ })
1048
+ }
883
1049
  // After a short delay (let calendar re-render), find the event and show popover
884
1050
  setTimeout(() => {
885
1051
  const booking = bookings.find((b: CalendarBooking) => b.id === newId)