@fayz-ai/plugin-agenda 0.12.2 → 0.13.1

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 (43) hide show
  1. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  2. package/dist/{chunk-ISLJXP7O.js → chunk-PDYBAVXU.js} +6 -4
  3. package/dist/chunk-PDYBAVXU.js.map +1 -0
  4. package/dist/components/AppointmentModal.d.ts.map +1 -1
  5. package/dist/components/CalendarSidebar.d.ts.map +1 -1
  6. package/dist/components/CheckinDialog.d.ts +24 -0
  7. package/dist/components/CheckinDialog.d.ts.map +1 -0
  8. package/dist/components/FilterRail.d.ts +42 -0
  9. package/dist/components/FilterRail.d.ts.map +1 -0
  10. package/dist/components/MiniCalendar.d.ts +1 -8
  11. package/dist/components/MiniCalendar.d.ts.map +1 -1
  12. package/dist/components/PaymentStatusBadge.d.ts +4 -9
  13. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  14. package/dist/components/StatusBadge.d.ts +6 -0
  15. package/dist/components/StatusBadge.d.ts.map +1 -1
  16. package/dist/config.d.ts +14 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/data/mock.d.ts.map +1 -1
  19. package/dist/data/supabase.d.ts.map +1 -1
  20. package/dist/data/types.d.ts +2 -2
  21. package/dist/data/types.d.ts.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2343 -472
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/google-calendar/connectorDef.d.ts +1 -1
  26. package/dist/integrations/google-calendar/credentials.d.ts +1 -1
  27. package/dist/locales/en.d.ts.map +1 -1
  28. package/dist/locales/pt-BR.d.ts.map +1 -1
  29. package/dist/migrations/index.d.ts +7 -21
  30. package/dist/migrations/index.d.ts.map +1 -1
  31. package/dist/public/index.js +2 -2
  32. package/dist/store.d.ts +8 -3
  33. package/dist/store.d.ts.map +1 -1
  34. package/dist/types.d.ts +17 -1
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/views/CalendarView.d.ts +16 -0
  37. package/dist/views/CalendarView.d.ts.map +1 -1
  38. package/dist/views/CheckinPage.d.ts +12 -0
  39. package/dist/views/CheckinPage.d.ts.map +1 -0
  40. package/dist/views/CheckinView.d.ts +13 -0
  41. package/dist/views/CheckinView.d.ts.map +1 -0
  42. package/package.json +5 -5
  43. package/dist/chunk-ISLJXP7O.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { createMockAgendaProvider, createSupabasePublicBookingProvider } from './chunk-ISLJXP7O.js';
2
- export { createMockAgendaProvider } from './chunk-ISLJXP7O.js';
3
- import React13, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
4
- import { connectionStatus, createConnectionStore, getSupabaseClientOptional, getActiveTenantId, registerTranslations, registerMessageDefaults, createSafeDataProvider, connectorRuntimeToken, CONNECTOR_RUNTIME_TOKEN_HEADER, emitDataChanged, usePluginRuntimeOptional, useTranslation, emitDomainEvent, getCurrentLocale, useDataChanged, formatCurrency } from '@fayz-ai/core';
5
- import { createPluginContext, fayzConnectorOAuth, setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel, dedup, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, usePluginPrefs, PersonLink, PageActions, useLimitGuard, useLookupCreate, RightRailPage, CrudFormPage, CrudDetailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
1
+ import { createMockAgendaProvider, createSupabasePublicBookingProvider } from './chunk-PDYBAVXU.js';
2
+ export { createMockAgendaProvider } from './chunk-PDYBAVXU.js';
3
+ import React11, { useState, useEffect, useCallback, useMemo, useRef, useLayoutEffect } from 'react';
4
+ import { connectionStatus, createConnectionStore, getSupabaseClientOptional, getActiveTenantId, registerTranslations, registerMessageDefaults, createSafeDataProvider, connectorRuntimeToken, CONNECTOR_RUNTIME_TOKEN_HEADER, getActiveUnitId, emitDataChanged, usePluginRuntimeOptional, useTranslation, emitDomainEvent, getCurrentLocale, useActiveUnitId, useDataChanged, formatCurrency } from '@fayz-ai/core';
5
+ import { createPluginContext, fayzConnectorOAuth, setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel, dedup, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, usePermissionOptional, PersonLink, usePluginPrefs, PageActions, PaymentStatusBadge as PaymentStatusBadge$1, RightRailPage, useLimitGuard, useLookupCreate, CrudFormPage, CrudDetailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
6
6
  import FullCalendar from '@fullcalendar/react';
7
7
  import dayGridPlugin from '@fullcalendar/daygrid';
8
8
  import timeGridPlugin from '@fullcalendar/timegrid';
@@ -10,10 +10,11 @@ import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid';
10
10
  import interactionPlugin from '@fullcalendar/interaction';
11
11
  import listPlugin from '@fullcalendar/list';
12
12
  import ptBrLocale from '@fullcalendar/core/locales/pt-br';
13
- import { Loader2, AlertCircle, RefreshCw, CalendarPlus, X, CheckCircle2, Plus, ChevronDown, Settings, ChevronLeft, ChevronRight, Pencil, Trash2, Clock, User, MapPin, FileText, AlertTriangle, Check, DollarSign, HandCoins, Ban, CheckSquare, Calendar, Briefcase, CircleDot, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, ExternalLink } from 'lucide-react';
13
+ import { Loader2, AlertCircle, RefreshCw, CalendarPlus, X, CheckCircle2, Search, LogIn, User, Clock, CalendarDays, Plus, Settings, ChevronLeft, ChevronRight, CircleAlert, CircleDollarSign, Ban, CheckSquare, Calendar, DollarSign, AlertTriangle, MapPin, Briefcase, CircleDot, FileText, Check, HandCoins, Pencil, Trash2, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, ExternalLink } from 'lucide-react';
14
+ import { toast, Button, Checkbox, Badge, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SubpageHeader, FilterSection, MiniCalendar, PageHeaderActions, ConfirmDialog, FilterCheckRow, SearchCombobox, cn, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@fayz-ai/ui';
14
15
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
15
16
  import { createPortal } from 'react-dom';
16
- import { toast, Button, Checkbox, Badge, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, PageHeaderActions, ConfirmDialog, SearchCombobox, cn, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@fayz-ai/ui';
17
+ import { ClientAccountModal } from '@fayz-ai/plugin-financial';
17
18
  import { createStore } from 'zustand/vanilla';
18
19
 
19
20
  // src/lib/message-events.ts
@@ -221,11 +222,24 @@ var DEFAULT_CURRENCY = { code: "BRL", locale: "pt-BR", symbol: "R$" };
221
222
  var DEFAULT_STATUSES = [
222
223
  { value: "scheduled", label: "Scheduled", color: "#6366f1" },
223
224
  { value: "confirmed", label: "Confirmed", color: "#3b82f6" },
225
+ { value: "waiting", label: "Waiting", color: "#f97316", availableWhen: "today_only" },
224
226
  { value: "in_progress", label: "In Progress", color: "#f59e0b", availableWhen: "today_only" },
225
227
  { value: "completed", label: "Completed", color: "#10b981", availableWhen: "today_or_past" },
226
228
  { value: "cancelled", label: "Cancelled", color: "#ef4444" },
227
229
  { value: "no_show", label: "No Show", color: "#6b7280", availableWhen: "today_or_past" }
228
230
  ];
231
+ function withCheckinStatus(statuses, checkin) {
232
+ if (!checkin || statuses.some((s) => s.value === "waiting")) return statuses;
233
+ const at = statuses.findIndex((s) => s.value === "in_progress");
234
+ const waiting = {
235
+ value: "waiting",
236
+ label: "Waiting",
237
+ color: "#f97316",
238
+ availableWhen: "today_only"
239
+ };
240
+ if (at < 0) return [...statuses, waiting];
241
+ return [...statuses.slice(0, at), waiting, ...statuses.slice(at)];
242
+ }
229
243
  var DEFAULT_BOOKING_TYPES = [
230
244
  {
231
245
  id: "appointment",
@@ -294,7 +308,11 @@ function resolveConfig(options) {
294
308
  bookingKind: options?.bookingKind ?? "appointment",
295
309
  orderKind: options?.orderKind ?? "service_order",
296
310
  scheduleKind: options?.scheduleKind ?? "working_hours",
297
- statuses: options?.statuses ?? DEFAULT_STATUSES,
311
+ // Check-in WRITES `waiting`, so enabling check-in has to declare it. An app
312
+ // that passes its own `statuses` (most do) would otherwise be missing the
313
+ // one status the feature produces — the calendar cannot colour or select it,
314
+ // and the checked-in client drops out of the very queue meant to track them.
315
+ statuses: withCheckinStatus(options?.statuses ?? DEFAULT_STATUSES, options?.checkin),
298
316
  bookingTypes,
299
317
  businessHours: options?.businessHours ?? { startTime: "07:00", endTime: "21:00" },
300
318
  slotDuration: options?.slotDuration ?? 30,
@@ -616,75 +634,62 @@ function useAgendaSettings() {
616
634
  setEndTime: (v) => prefs.set("endTime", v)
617
635
  };
618
636
  }
619
- var DAY_LABELS = {
620
- en: ["S", "M", "T", "W", "T", "F", "S"],
621
- "pt-BR": ["D", "S", "T", "Q", "Q", "S", "S"]
622
- };
623
- function isSameDay(a, b) {
624
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
625
- }
626
- function isToday(d) {
627
- return isSameDay(d, /* @__PURE__ */ new Date());
628
- }
629
- function toDateLocale(locale) {
630
- if (locale === "pt-BR") return "pt-BR";
631
- return "en-US";
632
- }
633
- function MiniCalendar({ selectedDate, onDateSelect, onMonthChange }) {
634
- const locale = getCurrentLocale();
635
- const [viewMonth, setViewMonth] = React13.useState(() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
636
- const monthLabel = viewMonth.toLocaleDateString(toDateLocale(locale), { month: "long", year: "numeric" });
637
- const dayLabels = DAY_LABELS[locale] ?? DAY_LABELS.en;
638
- const weeks = useMemo(() => {
639
- const year = viewMonth.getFullYear();
640
- const month = viewMonth.getMonth();
641
- const firstDay = new Date(year, month, 1);
642
- const startOffset = firstDay.getDay();
643
- const daysInMonth = new Date(year, month + 1, 0).getDate();
644
- const cells = [];
645
- for (let i = 0; i < startOffset; i++) cells.push(null);
646
- for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));
647
- while (cells.length % 7 !== 0) cells.push(null);
648
- const rows = [];
649
- for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
650
- return rows;
651
- }, [viewMonth]);
652
- function prevMonth() {
653
- const d = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1);
654
- setViewMonth(d);
655
- onMonthChange?.(d);
656
- }
657
- function nextMonth() {
658
- const d = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1);
659
- setViewMonth(d);
660
- onMonthChange?.(d);
661
- }
662
- function handleClick(date) {
663
- onDateSelect(date);
664
- }
665
- return /* @__PURE__ */ jsxs("div", { className: "select-none", children: [
666
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-2", children: [
667
- /* @__PURE__ */ jsx("span", { className: "text-sm font-medium capitalize", children: monthLabel }),
668
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-0.5", children: [
669
- /* @__PURE__ */ jsx("button", { onClick: prevMonth, className: "flex h-6 w-6 items-center justify-center rounded-full hover:bg-muted transition-colors", children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-3.5 w-3.5" }) }),
670
- /* @__PURE__ */ jsx("button", { onClick: nextMonth, className: "flex h-6 w-6 items-center justify-center rounded-full hover:bg-muted transition-colors", children: /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5" }) })
671
- ] })
637
+ function StatusFilterList({
638
+ statuses,
639
+ selected,
640
+ onToggle
641
+ }) {
642
+ return /* @__PURE__ */ jsx(Fragment, { children: statuses.map((st) => /* @__PURE__ */ jsx(
643
+ FilterCheckRow,
644
+ {
645
+ option: { value: st.value, label: st.label, color: st.color },
646
+ checked: selected.length === 0 || selected.includes(st.value),
647
+ onToggle: () => onToggle(st.value)
648
+ },
649
+ st.value
650
+ )) });
651
+ }
652
+ function ProfessionalFilterList({
653
+ names,
654
+ selected,
655
+ onToggle,
656
+ search,
657
+ onSearchChange,
658
+ searchPlaceholder,
659
+ maxRows = 40,
660
+ footer
661
+ }) {
662
+ const searchable = onSearchChange !== void 0 && (names.length > maxRows || Boolean(search));
663
+ const term = (search ?? "").trim().toLowerCase();
664
+ const matches = term ? names.filter((n) => n.label.toLowerCase().includes(term)) : names;
665
+ const shown = matches.slice(0, maxRows);
666
+ const hidden = matches.length - shown.length;
667
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
668
+ searchable && /* @__PURE__ */ jsx(
669
+ "input",
670
+ {
671
+ type: "search",
672
+ value: search ?? "",
673
+ onChange: (e) => onSearchChange?.(e.target.value),
674
+ placeholder: searchPlaceholder,
675
+ className: "mb-1.5 w-full rounded-md border border-border bg-background px-2 py-1 text-xs outline-none focus:border-primary"
676
+ }
677
+ ),
678
+ shown.map((item) => /* @__PURE__ */ jsx(
679
+ FilterCheckRow,
680
+ {
681
+ option: { value: item.id, label: item.label },
682
+ checked: selected.length === 0 || selected.includes(item.id),
683
+ onToggle: () => onToggle(item.id)
684
+ },
685
+ item.id
686
+ )),
687
+ matches.length === 0 && /* @__PURE__ */ jsx("p", { className: "px-1 py-1 text-xs text-muted-foreground", children: "\u2014" }),
688
+ hidden > 0 && /* @__PURE__ */ jsxs("p", { className: "px-1 py-1 text-[11px] text-muted-foreground", children: [
689
+ "+",
690
+ hidden
672
691
  ] }),
673
- /* @__PURE__ */ jsx("div", { className: "grid grid-cols-7 mb-0.5", children: dayLabels.map((label, i) => /* @__PURE__ */ jsx("div", { className: "flex h-6 w-6 items-center justify-center text-[10px] font-medium text-muted-foreground mx-auto", children: label }, i)) }),
674
- weeks.map((week, wi) => /* @__PURE__ */ jsx("div", { className: "grid grid-cols-7", children: week.map((date, di) => {
675
- if (!date) return /* @__PURE__ */ jsx("div", { className: "h-6 w-6 mx-auto" }, di);
676
- const selected = isSameDay(date, selectedDate);
677
- const today = isToday(date);
678
- return /* @__PURE__ */ jsx(
679
- "button",
680
- {
681
- onClick: () => handleClick(date),
682
- className: `flex h-6 w-6 items-center justify-center rounded-full text-[11px] mx-auto transition-colors ${selected ? "bg-primary text-primary-foreground" : today ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"}`,
683
- children: date.getDate()
684
- },
685
- di
686
- );
687
- }) }, wi))
692
+ footer
688
693
  ] });
689
694
  }
690
695
  var TYPE_ICONS = {
@@ -1351,6 +1356,11 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
1351
1356
  return "09:00";
1352
1357
  });
1353
1358
  }, [open, requestKey]);
1359
+ useEffect(() => {
1360
+ if (!open || locationId) return;
1361
+ const opts = storeLocations.length > 0 ? storeLocations : config.locations;
1362
+ if (opts.length === 1) setLocationId(opts[0].id);
1363
+ }, [open, locationId, storeLocations, config.locations]);
1354
1364
  useEffect(() => {
1355
1365
  if (!open || mode !== "edit" || !bookingId) return;
1356
1366
  setLoading(true);
@@ -2635,6 +2645,22 @@ function CalendarSkeleton({ profCount = 2 }) {
2635
2645
  ] }, h)) })
2636
2646
  ] });
2637
2647
  }
2648
+ var MIN_COLUMN_PX = 48;
2649
+ var MIN_SHELL_HEIGHT = 360;
2650
+ function abbreviateName(full) {
2651
+ const parts = full.trim().split(/\s+/).filter(Boolean);
2652
+ if (parts.length === 0) return full;
2653
+ const particles = /* @__PURE__ */ new Set(["de", "da", "do", "das", "dos", "e", "del", "di", "du", "van", "von"]);
2654
+ const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
2655
+ const first = cap(parts[0]);
2656
+ if (parts.length === 1) return first;
2657
+ const surnames = parts.slice(1).filter((w) => !particles.has(w.toLowerCase()));
2658
+ const last = surnames.length > 0 ? surnames[surnames.length - 1] : parts[parts.length - 1];
2659
+ return `${first} ${last.charAt(0).toUpperCase()}.`;
2660
+ }
2661
+ var MAX_DEFAULT_AGENDAS = 6;
2662
+ var MAX_SELECTED_AGENDAS = 10;
2663
+ var MAX_PICKER_ROWS = 40;
2638
2664
  var RESOURCE_VIEW_OPTIONS = [
2639
2665
  { key: "resourceTimeGridDay", labelKey: "agenda.calendar.viewDay" },
2640
2666
  { key: "resourceTimeGridWeek", labelKey: "agenda.calendar.viewWeek" },
@@ -2686,6 +2712,7 @@ function CalendarView() {
2686
2712
  const bookingsLoading = useAgendaStore((s) => s.bookingsLoading);
2687
2713
  const professionals = useAgendaStore((s) => s.professionals);
2688
2714
  const professionalsLoading = useAgendaStore((s) => s.professionalsLoading);
2715
+ const professionalsTruncated = useAgendaStore((s) => s.professionalsTruncated);
2689
2716
  const currentView = useAgendaStore((s) => s.currentView);
2690
2717
  const selectedProfIds = useAgendaStore((s) => s.selectedProfessionalIds);
2691
2718
  const selectedStatuses = useAgendaStore((s) => s.selectedStatuses);
@@ -2704,6 +2731,26 @@ function CalendarView() {
2704
2731
  const isMobile = useIsMobile();
2705
2732
  const resourceCapable = professionals.length > 0;
2706
2733
  const viewOptions = resourceCapable ? RESOURCE_VIEW_OPTIONS : PLAIN_VIEW_OPTIONS;
2734
+ const shellRef = useRef(null);
2735
+ const [shellHeight, setShellHeight] = useState(void 0);
2736
+ useLayoutEffect(() => {
2737
+ const measure = () => {
2738
+ const el = shellRef.current;
2739
+ if (!el) return;
2740
+ const top = el.getBoundingClientRect().top;
2741
+ const parent = el.parentElement;
2742
+ const pad = parent ? parseFloat(getComputedStyle(parent).paddingBottom) || 0 : 0;
2743
+ setShellHeight(Math.max(MIN_SHELL_HEIGHT, window.innerHeight - top - pad));
2744
+ };
2745
+ measure();
2746
+ window.addEventListener("resize", measure);
2747
+ const ro = new ResizeObserver(measure);
2748
+ if (document.body) ro.observe(document.body);
2749
+ return () => {
2750
+ window.removeEventListener("resize", measure);
2751
+ ro.disconnect();
2752
+ };
2753
+ }, []);
2707
2754
  const [mobileView, setMobileView] = useState(MOBILE_LIST_VIEW);
2708
2755
  const mobileViewOptions = useMemo(
2709
2756
  () => [
@@ -2731,8 +2778,6 @@ function CalendarView() {
2731
2778
  const [scheduleConfirm, setScheduleConfirm] = useState(null);
2732
2779
  const [calendarTitle, setCalendarTitle] = useState("");
2733
2780
  const [selectedDate, setSelectedDate] = useState(/* @__PURE__ */ new Date());
2734
- const [profSectionOpen, setProfSectionOpen] = useState(true);
2735
- const [statusSectionOpen, setStatusSectionOpen] = useState(false);
2736
2781
  const [visibleRange, setVisibleRange] = useState(null);
2737
2782
  useEffect(() => () => closeModal(), [closeModal]);
2738
2783
  const openModal = useCallback((...args) => {
@@ -2748,11 +2793,33 @@ function CalendarView() {
2748
2793
  for (const s of config.statuses) map[s.value] = s.color;
2749
2794
  return map;
2750
2795
  }, [config.statuses]);
2796
+ const activeUnitId = useActiveUnitId();
2751
2797
  useEffect(() => {
2752
2798
  fetchProfessionals();
2753
2799
  fetchSchedules();
2754
2800
  if (config.modules.locationSelection) fetchLocations();
2755
- }, []);
2801
+ }, [activeUnitId]);
2802
+ const seededDefault = useRef(false);
2803
+ useEffect(() => {
2804
+ if (seededDefault.current) return;
2805
+ if (professionals.length === 0) return;
2806
+ seededDefault.current = true;
2807
+ if (selectedProfIds.length > 0) return;
2808
+ if (professionals.length <= MAX_DEFAULT_AGENDAS) return;
2809
+ setFilters({ professionalIds: professionals.slice(0, MAX_DEFAULT_AGENDAS).map((p) => p.id) });
2810
+ }, [professionals]);
2811
+ useEffect(() => {
2812
+ seededDefault.current = false;
2813
+ }, [activeUnitId]);
2814
+ const [profSearch, setProfSearch] = useState("");
2815
+ useEffect(() => {
2816
+ if (!professionalsTruncated) return;
2817
+ const term = profSearch.trim();
2818
+ const timer = setTimeout(() => {
2819
+ void fetchProfessionals(term ? { search: term } : void 0);
2820
+ }, 250);
2821
+ return () => clearTimeout(timer);
2822
+ }, [profSearch, professionalsTruncated]);
2756
2823
  useEffect(() => {
2757
2824
  const api = calendarRef.current?.getApi();
2758
2825
  if (!api) return;
@@ -2813,15 +2880,19 @@ function CalendarView() {
2813
2880
  unsubscribe?.();
2814
2881
  };
2815
2882
  }, [provider2, refreshVisibleBookings]);
2816
- const resources = useMemo(() => {
2817
- const filtered = selectedProfIds.length > 0 ? professionals.filter((p) => selectedProfIds.includes(p.id)) : professionals;
2818
- return filtered.map((p) => ({
2819
- id: p.id,
2820
- title: p.name,
2821
- extendedProps: { avatarUrl: p.avatarUrl, locationName: p.locationName }
2822
- }));
2823
- }, [professionals, selectedProfIds]);
2883
+ const drawnProfessionals = useMemo(() => selectedProfIds.length > 0 ? professionals.filter((p) => selectedProfIds.includes(p.id)) : professionals.slice(0, MAX_SELECTED_AGENDAS), [professionals, selectedProfIds]);
2884
+ const resources = useMemo(() => drawnProfessionals.map((p) => ({
2885
+ id: p.id,
2886
+ title: p.name,
2887
+ extendedProps: { avatarUrl: p.avatarUrl, locationName: p.locationName }
2888
+ })), [drawnProfessionals]);
2824
2889
  const visibleResourceIds = useMemo(() => resources.map((resource) => resource.id), [resources]);
2890
+ const gridMinWidth = useMemo(() => {
2891
+ if (!currentView.startsWith("resource")) return void 0;
2892
+ const daysInView = currentView.endsWith("Day") ? 1 : 7;
2893
+ const columns = Math.max(1, resources.length) * daysInView;
2894
+ return columns * MIN_COLUMN_PX + 64;
2895
+ }, [currentView, resources.length]);
2825
2896
  const bookingEvents = useMemo(
2826
2897
  () => bookings.map((b) => bookingToEvent(b, statusColors, visibleResourceIds)),
2827
2898
  [bookings, statusColors, visibleResourceIds]
@@ -2837,8 +2908,8 @@ function CalendarView() {
2837
2908
  return allLocations;
2838
2909
  }, [config.modules.locationSelection, allLocations]);
2839
2910
  const availabilityEvents = useMemo(
2840
- () => generateAvailabilityEvents(schedules, professionals, visibleRange, effectiveBusinessHours, locationMap),
2841
- [schedules, professionals, visibleRange, effectiveBusinessHours, locationMap]
2911
+ () => generateAvailabilityEvents(schedules, drawnProfessionals, visibleRange, effectiveBusinessHours, locationMap),
2912
+ [schedules, drawnProfessionals, visibleRange, effectiveBusinessHours, locationMap]
2842
2913
  );
2843
2914
  const events = useMemo(() => [...bookingEvents, ...availabilityEvents], [bookingEvents, availabilityEvents]);
2844
2915
  const storedView = prefs.get("calendarView");
@@ -2994,8 +3065,15 @@ function CalendarView() {
2994
3065
  calendarRef.current?.getApi().gotoDate(date);
2995
3066
  }
2996
3067
  function toggleProfessional(id) {
2997
- const next = selectedProfIds.includes(id) ? selectedProfIds.filter((p) => p !== id) : [...selectedProfIds, id];
2998
- setFilters({ professionalIds: next });
3068
+ if (selectedProfIds.includes(id)) {
3069
+ setFilters({ professionalIds: selectedProfIds.filter((p) => p !== id) });
3070
+ return;
3071
+ }
3072
+ if (selectedProfIds.length >= MAX_SELECTED_AGENDAS) {
3073
+ toast.error(t2("agenda.sidebar.tooManyAgendas", { max: String(MAX_SELECTED_AGENDAS) }));
3074
+ return;
3075
+ }
3076
+ setFilters({ professionalIds: [...selectedProfIds, id] });
2999
3077
  }
3000
3078
  function toggleStatus(value) {
3001
3079
  const next = selectedStatuses.includes(value) ? selectedStatuses.filter((s) => s !== value) : [...selectedStatuses, value];
@@ -3129,14 +3207,14 @@ function CalendarView() {
3129
3207
  const resourceLabelContent = useCallback((arg) => {
3130
3208
  const { id, title, extendedProps } = arg.resource;
3131
3209
  const initials = title.split(" ").map((n) => n[0]).join("").slice(0, 2);
3132
- return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-2 py-1.5", children: [
3210
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-2 py-1.5", title, children: [
3133
3211
  /* @__PURE__ */ jsx("div", { className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary", children: initials }),
3134
3212
  /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
3135
3213
  /* @__PURE__ */ jsx(
3136
3214
  PersonLink,
3137
3215
  {
3138
3216
  personId: id,
3139
- name: title,
3217
+ name: abbreviateName(title),
3140
3218
  size: "sm",
3141
3219
  tab: "schedule",
3142
3220
  profileHref: `#/registry/staff/${id}/schedule`
@@ -3163,7 +3241,7 @@ function CalendarView() {
3163
3241
  timeRange && /* @__PURE__ */ jsx("p", { className: "text-[9px] opacity-50", children: timeRange })
3164
3242
  ] });
3165
3243
  }, []);
3166
- return /* @__PURE__ */ jsxs("div", { className: "flex h-[calc(100vh-7.5rem)]", children: [
3244
+ return /* @__PURE__ */ jsxs("div", { ref: shellRef, className: "flex", style: { height: shellHeight ?? void 0 }, children: [
3167
3245
  /* @__PURE__ */ jsxs("div", { className: "hidden md:flex w-[220px] shrink-0 flex-col overflow-y-auto py-4 px-3 mr-4", children: [
3168
3246
  /* @__PURE__ */ jsxs(
3169
3247
  "button",
@@ -3179,54 +3257,29 @@ function CalendarView() {
3179
3257
  /* @__PURE__ */ jsx(MiniCalendar, { selectedDate, onDateSelect: handleMiniDateSelect }),
3180
3258
  config.capabilities.professional && /* @__PURE__ */ jsxs(Fragment, { children: [
3181
3259
  /* @__PURE__ */ jsx("div", { className: "h-px bg-border my-4" }),
3182
- /* @__PURE__ */ jsxs(
3183
- "button",
3260
+ /* @__PURE__ */ jsx(FilterSection, { title: t2("agenda.sidebar.agendas"), children: /* @__PURE__ */ jsx(
3261
+ ProfessionalFilterList,
3184
3262
  {
3185
- onClick: () => setProfSectionOpen((p) => !p),
3186
- className: "flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors",
3187
- children: [
3188
- /* @__PURE__ */ jsx("span", { children: "Agendas" }),
3189
- /* @__PURE__ */ jsx(ChevronDown, { className: `h-3.5 w-3.5 transition-transform ${profSectionOpen ? "" : "-rotate-90"}` })
3190
- ]
3263
+ names: professionals.map((p) => ({ id: p.id, label: p.name })),
3264
+ selected: selectedProfIds,
3265
+ onToggle: toggleProfessional,
3266
+ search: profSearch,
3267
+ onSearchChange: setProfSearch,
3268
+ searchPlaceholder: t2("agenda.sidebar.searchAgendas"),
3269
+ maxRows: MAX_PICKER_ROWS
3191
3270
  }
3192
- ),
3193
- profSectionOpen && /* @__PURE__ */ jsx("div", { className: "mt-1.5 space-y-0.5", children: professionals.map((prof) => {
3194
- const active = selectedProfIds.length === 0 || selectedProfIds.includes(prof.id);
3195
- return /* @__PURE__ */ jsxs("label", { className: "flex items-center gap-2.5 rounded px-1 py-1 cursor-pointer hover:bg-muted/50 transition-colors", children: [
3196
- /* @__PURE__ */ jsx("span", { className: `flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border-2 transition-colors ${active ? "border-primary bg-primary" : "border-muted-foreground/30"}`, children: active && /* @__PURE__ */ jsx("svg", { className: "h-2.5 w-2.5 text-primary-foreground", viewBox: "0 0 12 12", fill: "none", children: /* @__PURE__ */ jsx("path", { d: "M2 6l3 3 5-5", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) }),
3197
- /* @__PURE__ */ jsx("span", { className: `text-xs truncate ${active ? "text-foreground" : "text-muted-foreground"}`, children: prof.name }),
3198
- /* @__PURE__ */ jsx("input", { type: "checkbox", className: "sr-only", checked: active, onChange: () => toggleProfessional(prof.id) })
3199
- ] }, prof.id);
3200
- }) })
3271
+ ) })
3201
3272
  ] }),
3202
3273
  config.capabilities.status && /* @__PURE__ */ jsxs(Fragment, { children: [
3203
3274
  /* @__PURE__ */ jsx("div", { className: "h-px bg-border my-4" }),
3204
- /* @__PURE__ */ jsxs(
3205
- "button",
3275
+ /* @__PURE__ */ jsx(FilterSection, { title: t2("agenda.sidebar.status"), children: /* @__PURE__ */ jsx(
3276
+ StatusFilterList,
3206
3277
  {
3207
- onClick: () => setStatusSectionOpen((p) => !p),
3208
- className: "flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground py-1 transition-colors",
3209
- children: [
3210
- /* @__PURE__ */ jsx("span", { children: "Status" }),
3211
- /* @__PURE__ */ jsx(ChevronDown, { className: `h-3.5 w-3.5 transition-transform ${statusSectionOpen ? "" : "-rotate-90"}` })
3212
- ]
3278
+ statuses: config.statuses,
3279
+ selected: selectedStatuses,
3280
+ onToggle: toggleStatus
3213
3281
  }
3214
- ),
3215
- statusSectionOpen && /* @__PURE__ */ jsx("div", { className: "mt-1.5 space-y-0.5", children: config.statuses.map((st) => {
3216
- const active = selectedStatuses.length === 0 || selectedStatuses.includes(st.value);
3217
- return /* @__PURE__ */ jsxs("label", { className: "flex items-center gap-2.5 rounded px-1 py-1 cursor-pointer hover:bg-muted/50 transition-colors", children: [
3218
- /* @__PURE__ */ jsx(
3219
- "span",
3220
- {
3221
- className: "flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border-2 transition-colors",
3222
- style: { borderColor: active ? st.color : st.color + "40", backgroundColor: active ? st.color : "transparent" },
3223
- children: active && /* @__PURE__ */ jsx("svg", { className: "h-2.5 w-2.5 text-white", viewBox: "0 0 12 12", fill: "none", children: /* @__PURE__ */ jsx("path", { d: "M2 6l3 3 5-5", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) })
3224
- }
3225
- ),
3226
- /* @__PURE__ */ jsx("span", { className: `text-xs ${active ? "text-foreground" : "text-muted-foreground"}`, children: st.label }),
3227
- /* @__PURE__ */ jsx("input", { type: "checkbox", className: "sr-only", checked: active, onChange: () => toggleStatus(st.value) })
3228
- ] }, st.value);
3229
- }) })
3282
+ ) })
3230
3283
  ] })
3231
3284
  ] }),
3232
3285
  isMobile && /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(
@@ -3256,7 +3309,7 @@ function CalendarView() {
3256
3309
  ]
3257
3310
  }
3258
3311
  ) }),
3259
- /* @__PURE__ */ jsxs("div", { className: "flex-1 flex flex-col min-w-0 overflow-hidden", children: [
3312
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden", children: [
3260
3313
  /* @__PURE__ */ jsxs("div", { className: "shrink-0 border-b", children: [
3261
3314
  /* @__PURE__ */ jsxs("div", { className: `${mobileGridActive ? "flex" : "hidden"} md:flex h-12 items-center justify-between pl-4 pr-4`, children: [
3262
3315
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 min-w-0", children: [
@@ -3325,7 +3378,7 @@ function CalendarView() {
3325
3378
  onCreate: () => openModal("create")
3326
3379
  }
3327
3380
  )
3328
- ) : professionalsLoading && professionals.length === 0 ? /* @__PURE__ */ jsx(CalendarSkeleton, { profCount: 2 }) : /* @__PURE__ */ jsx("div", { ref: wrapperRef, className: "flex-1 overflow-hidden agenda-calendar", children: /* @__PURE__ */ jsx(
3381
+ ) : professionalsLoading && professionals.length === 0 ? /* @__PURE__ */ jsx(CalendarSkeleton, { profCount: 2 }) : /* @__PURE__ */ jsx("div", { ref: wrapperRef, className: "min-h-0 min-w-0 flex-1 overflow-x-auto overflow-y-hidden agenda-calendar", children: /* @__PURE__ */ jsx("div", { className: "h-full", style: gridMinWidth ? { minWidth: gridMinWidth } : void 0, children: /* @__PURE__ */ jsx(
3329
3382
  FullCalendar,
3330
3383
  {
3331
3384
  ref: calendarRef,
@@ -3362,7 +3415,7 @@ function CalendarView() {
3362
3415
  eventTimeFormat: { hour: "2-digit", minute: "2-digit", hour12: false },
3363
3416
  slotLabelFormat: { hour: "2-digit", minute: "2-digit", hour12: false }
3364
3417
  }
3365
- ) })
3418
+ ) }) })
3366
3419
  ] }),
3367
3420
  selectedBooking && popoverAnchor && popoverVisible && /* @__PURE__ */ jsx(
3368
3421
  AppointmentPopover,
@@ -3464,12 +3517,409 @@ function getAgendaTenantId() {
3464
3517
  function AgendaPage({ config, provider: provider2, store }) {
3465
3518
  const runtime = usePluginRuntimeOptional();
3466
3519
  const tenantId = runtime?.context.tenant?.id;
3467
- React13.useEffect(() => {
3520
+ React11.useEffect(() => {
3468
3521
  setAgendaTenantId(tenantId);
3469
3522
  return () => setAgendaTenantId(void 0);
3470
3523
  }, [tenantId]);
3471
3524
  return /* @__PURE__ */ jsx(AgendaContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsx(CalendarView, {}) });
3472
3525
  }
3526
+ var brl = (n) => n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
3527
+ function CheckinDialog({
3528
+ booking,
3529
+ statusLabel,
3530
+ requirements = [],
3531
+ onReceive,
3532
+ onConfirm,
3533
+ onClose,
3534
+ formatCurrency: formatCurrency2 = brl
3535
+ }) {
3536
+ const [busy, setBusy] = React11.useState(false);
3537
+ const blocking = requirements.filter((r) => r.blocking);
3538
+ const paid = booking.paymentStatus === "paid";
3539
+ const hasBill = !!booking.paymentStatus && booking.paymentStatus !== "none";
3540
+ const when = new Date(booking.startsAt).toLocaleString("pt-BR", {
3541
+ weekday: "long",
3542
+ day: "2-digit",
3543
+ month: "2-digit",
3544
+ hour: "2-digit",
3545
+ minute: "2-digit"
3546
+ });
3547
+ return /* @__PURE__ */ jsx(
3548
+ RightRailPage,
3549
+ {
3550
+ title: `Check-in \u2014 ${booking.clientName ?? "Sem cliente"}`,
3551
+ open: true,
3552
+ onClose,
3553
+ width: "max-w-md",
3554
+ children: /* @__PURE__ */ jsxs("div", { className: "space-y-3 px-4 py-3", children: [
3555
+ /* @__PURE__ */ jsxs("p", { className: "truncate text-xs text-muted-foreground", children: [
3556
+ when,
3557
+ booking.professionalName ? ` \xB7 ${booking.professionalName}` : ""
3558
+ ] }),
3559
+ /* @__PURE__ */ jsxs("section", { className: "rounded-xl border px-3 py-2", children: [
3560
+ /* @__PURE__ */ jsx("p", { className: "text-[10px] font-medium uppercase text-muted-foreground", children: "Agendamento" }),
3561
+ /* @__PURE__ */ jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-2 text-xs", children: [
3562
+ /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
3563
+ /* @__PURE__ */ jsx(Clock, { className: "h-3 w-3" }),
3564
+ new Date(booking.startsAt).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })
3565
+ ] }),
3566
+ /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 font-medium", children: statusLabel }),
3567
+ booking.services?.length ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: booking.services.map((s) => s.name).join(", ") }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "Servi\xE7o n\xE3o informado" })
3568
+ ] })
3569
+ ] }),
3570
+ /* @__PURE__ */ jsxs("section", { className: "rounded-xl border px-3 py-2", children: [
3571
+ /* @__PURE__ */ jsx("p", { className: "text-[10px] font-medium uppercase text-muted-foreground", children: "Cadastro do cliente" }),
3572
+ requirements.length === 0 ? /* @__PURE__ */ jsxs("p", { className: "mt-1 inline-flex items-center gap-1.5 text-xs text-success", children: [
3573
+ /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5" }),
3574
+ " Dados obrigat\xF3rios completos"
3575
+ ] }) : /* @__PURE__ */ jsx("ul", { className: "mt-1 space-y-1", children: requirements.map((r) => /* @__PURE__ */ jsxs("li", { className: "flex items-start gap-1.5 text-xs", children: [
3576
+ /* @__PURE__ */ jsx(CircleAlert, { className: `mt-0.5 h-3.5 w-3.5 shrink-0 ${r.blocking ? "text-destructive" : "text-warning"}` }),
3577
+ /* @__PURE__ */ jsxs("span", { children: [
3578
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: r.label }),
3579
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
3580
+ " \u2014 ",
3581
+ r.detail
3582
+ ] })
3583
+ ] })
3584
+ ] }, r.label)) })
3585
+ ] }),
3586
+ /* @__PURE__ */ jsxs("section", { className: "rounded-xl border px-3 py-2", children: [
3587
+ /* @__PURE__ */ jsx("p", { className: "text-[10px] font-medium uppercase text-muted-foreground", children: "Pagamento" }),
3588
+ /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
3589
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: paid ? "Este atendimento j\xE1 est\xE1 pago." : hasBill ? `Em aberto${booking.orderTotal ? ` \xB7 ${formatCurrency2(booking.orderTotal)}` : ""}.` : "Nenhuma fatura vinculada a este agendamento." }),
3590
+ onReceive && !paid && /* @__PURE__ */ jsxs(
3591
+ "button",
3592
+ {
3593
+ onClick: () => onReceive(booking),
3594
+ className: "inline-flex shrink-0 items-center gap-1.5 rounded-button border border-input px-3 py-1.5 text-xs font-medium hover:bg-muted",
3595
+ children: [
3596
+ /* @__PURE__ */ jsx(CircleDollarSign, { className: "h-3.5 w-3.5" }),
3597
+ " Receber"
3598
+ ]
3599
+ }
3600
+ )
3601
+ ] })
3602
+ ] }),
3603
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-t pt-3", children: [
3604
+ /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground", children: blocking.length ? `Falta ${blocking.map((r) => r.label).join(", ")}.` : "" }),
3605
+ /* @__PURE__ */ jsxs(
3606
+ "button",
3607
+ {
3608
+ disabled: busy || blocking.length > 0,
3609
+ onClick: async () => {
3610
+ setBusy(true);
3611
+ try {
3612
+ await onConfirm(booking);
3613
+ } finally {
3614
+ setBusy(false);
3615
+ }
3616
+ },
3617
+ className: "inline-flex shrink-0 items-center gap-1.5 rounded-button bg-primary border border-primary px-4 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50",
3618
+ children: [
3619
+ /* @__PURE__ */ jsx(User, { className: "h-3.5 w-3.5" }),
3620
+ " ",
3621
+ busy ? "Concluindo\u2026" : "Concluir check-in"
3622
+ ]
3623
+ }
3624
+ )
3625
+ ] })
3626
+ ] })
3627
+ }
3628
+ );
3629
+ }
3630
+ function PaymentStatusBadge(props) {
3631
+ const t2 = useTranslation();
3632
+ const labels = React11.useMemo(() => ({
3633
+ none: t2("agenda.payment.title"),
3634
+ pending: t2("agenda.payment.pending"),
3635
+ partial: t2("agenda.payment.partial"),
3636
+ paid: t2("agenda.payment.paid"),
3637
+ overdue: t2("agenda.payment.overdue"),
3638
+ cancelled: t2("agenda.payment.cancelled")
3639
+ }), [t2]);
3640
+ return /* @__PURE__ */ jsx(PaymentStatusBadge$1, { ...props, labels });
3641
+ }
3642
+ var CHECKIN_STATUSES = ["scheduled", "confirmed", "waiting", "in_progress"];
3643
+ var STATUS_TONE = {
3644
+ scheduled: "bg-muted text-muted-foreground",
3645
+ confirmed: "bg-primary/10 text-primary",
3646
+ waiting: "bg-warning/15 text-warning-foreground",
3647
+ in_progress: "bg-success/15 text-success"
3648
+ };
3649
+ var PAYMENT_TEXT = {
3650
+ paid: { label: "Pago", tone: "text-success" },
3651
+ partial: { label: "Parcial", tone: "text-warning" },
3652
+ pending: { label: "Em aberto", tone: "text-destructive" },
3653
+ overdue: { label: "Vencido", tone: "text-destructive" }
3654
+ };
3655
+ function todayRange() {
3656
+ const start = /* @__PURE__ */ new Date();
3657
+ start.setHours(0, 0, 0, 0);
3658
+ const end = /* @__PURE__ */ new Date();
3659
+ end.setHours(23, 59, 59, 999);
3660
+ return { start: start.toISOString(), end: end.toISOString() };
3661
+ }
3662
+ function waitedFor(iso) {
3663
+ const min = Math.floor((Date.now() - new Date(iso).getTime()) / 6e4);
3664
+ if (min < 1) return "agora mesmo";
3665
+ if (min < 60) return `h\xE1 ${min} min`;
3666
+ const h = Math.floor(min / 60);
3667
+ return `h\xE1 ${h}h${min % 60 ? ` ${min % 60}min` : ""}`;
3668
+ }
3669
+ function hourOf(iso) {
3670
+ return new Date(iso).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
3671
+ }
3672
+ function CheckinView({ onReceive, onOpenBooking }) {
3673
+ const config = useAgendaConfig();
3674
+ const statusLabel = (v) => config.statuses.find((x) => x.value === v)?.label ?? v;
3675
+ const checkinStatuses = React11.useMemo(
3676
+ () => CHECKIN_STATUSES.map((v) => config.statuses.find((x) => x.value === v)).filter((x) => !!x),
3677
+ [config.statuses]
3678
+ );
3679
+ const bookings = useAgendaStore((s) => s.bookings);
3680
+ const loading = useAgendaStore((s) => s.bookingsLoading);
3681
+ const fetchBookings = useAgendaStore((s) => s.fetchBookings);
3682
+ const updateBookingStatus = useAgendaStore((s) => s.updateBookingStatus);
3683
+ const [term, setTerm] = React11.useState("");
3684
+ const [busyId, setBusyId] = React11.useState(null);
3685
+ const [panel, setPanel] = React11.useState(null);
3686
+ const reviewing = panel?.kind === "review" ? panel.booking : null;
3687
+ const editing = panel?.kind === "edit" ? panel.booking : null;
3688
+ const charging = panel?.kind === "charge" ? panel.booking : null;
3689
+ const setReviewing = (b) => setPanel(b ? { kind: "review", booking: b } : null);
3690
+ const setEditing = (b) => setPanel(b ? { kind: "edit", booking: b } : null);
3691
+ const setCharging = (b) => setPanel(b ? { kind: "charge", booking: b } : null);
3692
+ const can = usePermissionOptional();
3693
+ const canReceive = can("financial", "create") || can("financial.receivables", "create");
3694
+ React11.useEffect(() => {
3695
+ void fetchBookings(todayRange(), [...CHECKIN_STATUSES]);
3696
+ }, [fetchBookings]);
3697
+ const [shownStatuses, setShownStatuses] = React11.useState([]);
3698
+ const [shownPros, setShownPros] = React11.useState([]);
3699
+ const prosToday = React11.useMemo(() => {
3700
+ const seen = /* @__PURE__ */ new Map();
3701
+ for (const b of bookings) if (b.professionalName) seen.set(b.professionalName, b.professionalName);
3702
+ return [...seen.keys()].sort();
3703
+ }, [bookings]);
3704
+ const cards = React11.useMemo(() => {
3705
+ const q = term.trim().toLowerCase();
3706
+ return bookings.filter((b) => !shownStatuses.length || shownStatuses.includes(b.status)).filter((b) => !shownPros.length || b.professionalName && shownPros.includes(b.professionalName)).filter((b) => !q || (b.clientName ?? "").toLowerCase().includes(q) || (b.professionalName ?? "").toLowerCase().includes(q)).sort((a, b) => a.startsAt.localeCompare(b.startsAt));
3707
+ }, [bookings, term, shownStatuses, shownPros]);
3708
+ const waiting = cards.filter((b) => b.status === "waiting").length;
3709
+ function requirementsFor(b) {
3710
+ const out = [];
3711
+ if (!b.clientName) out.push({ label: "Cliente", detail: "o agendamento n\xE3o nomeia ningu\xE9m", blocking: true });
3712
+ if (!b.professionalName) out.push({ label: "Profissional", detail: "ningu\xE9m para executar" });
3713
+ if (!b.services?.length) out.push({ label: "Servi\xE7o", detail: "nada a executar nem a cobrar" });
3714
+ return out;
3715
+ }
3716
+ const money = (n) => n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
3717
+ async function loadPaymentLines(orderId) {
3718
+ const detail = await config.financialBridge?.getPaymentDetail(orderId);
3719
+ return (detail?.movements ?? []).filter((m) => m.paymentDate).map((m) => ({
3720
+ label: [m.paymentMethodTypeName, m.cardBrand, m.cardInstallments && m.cardInstallments > 1 ? `${m.cardInstallments}x` : null].filter(Boolean).join(" \xB7 ") || "Pagamento",
3721
+ date: m.paymentDate ?? null,
3722
+ amount: m.paidAmount
3723
+ }));
3724
+ }
3725
+ async function confirmCheckin(b) {
3726
+ setBusyId(b.id);
3727
+ try {
3728
+ await updateBookingStatus(b.id, "waiting");
3729
+ await fetchBookings(todayRange(), [...CHECKIN_STATUSES]);
3730
+ setReviewing(null);
3731
+ } finally {
3732
+ setBusyId(null);
3733
+ }
3734
+ }
3735
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
3736
+ /* @__PURE__ */ jsx(
3737
+ SubpageHeader,
3738
+ {
3739
+ title: "Check-in",
3740
+ subtitle: `Quem chega hoje${waiting ? ` \xB7 ${waiting} aguardando` : ""}`
3741
+ }
3742
+ ),
3743
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4 lg:grid-cols-[248px_1fr]", children: [
3744
+ /* @__PURE__ */ jsxs("aside", { className: "space-y-6 lg:sticky lg:top-4 lg:self-start", children: [
3745
+ /* @__PURE__ */ jsxs("div", { className: "relative", children: [
3746
+ /* @__PURE__ */ jsx(Search, { className: "absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }),
3747
+ /* @__PURE__ */ jsx(
3748
+ "input",
3749
+ {
3750
+ value: term,
3751
+ onChange: (e) => setTerm(e.target.value),
3752
+ placeholder: "Buscar cliente ou profissional",
3753
+ className: "w-full rounded-input border border-input bg-card py-2 pl-8 pr-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
3754
+ }
3755
+ )
3756
+ ] }),
3757
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsxs(FilterSection, { title: "Agendas", children: [
3758
+ loading && prosToday.length === 0 && [0, 1, 2].map((i) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2.5 px-1 py-1", children: [
3759
+ /* @__PURE__ */ jsx("div", { className: "h-4 w-4 animate-pulse rounded-sm bg-muted" }),
3760
+ /* @__PURE__ */ jsx("div", { className: "h-3.5 w-24 animate-pulse rounded bg-muted/70" })
3761
+ ] }, i)),
3762
+ /* @__PURE__ */ jsx(
3763
+ ProfessionalFilterList,
3764
+ {
3765
+ names: prosToday.map((name) => ({ id: name, label: name })),
3766
+ selected: shownPros,
3767
+ onToggle: (name) => setShownPros((cur) => cur.includes(name) ? cur.filter((x) => x !== name) : [...cur, name])
3768
+ }
3769
+ )
3770
+ ] }) }),
3771
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(FilterSection, { title: "Status", children: /* @__PURE__ */ jsx(
3772
+ StatusFilterList,
3773
+ {
3774
+ statuses: checkinStatuses,
3775
+ selected: shownStatuses,
3776
+ onToggle: (value) => setShownStatuses((cur) => cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])
3777
+ }
3778
+ ) }) }),
3779
+ /* @__PURE__ */ jsxs("p", { className: "px-2 text-xs tabular-nums text-muted-foreground", children: [
3780
+ cards.length,
3781
+ " de ",
3782
+ bookings.length
3783
+ ] })
3784
+ ] }),
3785
+ /* @__PURE__ */ jsx("div", { className: "min-w-0 space-y-2", children: loading && cards.length === 0 ? (
3786
+ /* Esqueleto com a FORMA da linha: a fila não salta quando os dados
3787
+ chegam, e a recepção já sabe onde olhar antes de haver o que ler. */
3788
+ /* @__PURE__ */ jsx("ul", { className: "space-y-2", "aria-busy": "true", "aria-label": "Carregando a fila de hoje", children: [0, 1, 2, 3].map((i) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3", children: [
3789
+ /* @__PURE__ */ jsx("div", { className: "h-4 w-12 animate-pulse rounded bg-muted" }),
3790
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [
3791
+ /* @__PURE__ */ jsx("div", { className: "h-3.5 w-40 animate-pulse rounded bg-muted" }),
3792
+ /* @__PURE__ */ jsx("div", { className: "h-3 w-24 animate-pulse rounded bg-muted/70" })
3793
+ ] }),
3794
+ /* @__PURE__ */ jsx("div", { className: "h-4 w-16 animate-pulse rounded bg-muted" }),
3795
+ /* @__PURE__ */ jsx("div", { className: "h-6 w-20 animate-pulse rounded-full bg-muted" }),
3796
+ /* @__PURE__ */ jsx("div", { className: "h-7 w-24 animate-pulse rounded-button bg-muted" })
3797
+ ] }, i)) })
3798
+ ) : cards.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "rounded-2xl border-2 border-dashed border-muted px-6 py-12 text-center", children: [
3799
+ /* @__PURE__ */ jsx("div", { className: "mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted text-muted-foreground", children: /* @__PURE__ */ jsx(LogIn, { className: "h-6 w-6" }) }),
3800
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: "Ningu\xE9m na fila" }),
3801
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: term ? "Nenhum atendimento de hoje casa com a busca." : "N\xE3o h\xE1 atendimentos para hoje." })
3802
+ ] }) : /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: cards.map((b) => {
3803
+ b.paymentStatus ? PAYMENT_TEXT[b.paymentStatus] : void 0;
3804
+ const arrived = b.status === "waiting" || b.status === "in_progress";
3805
+ return /* @__PURE__ */ jsxs(
3806
+ "li",
3807
+ {
3808
+ className: "flex flex-wrap items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3",
3809
+ children: [
3810
+ /* @__PURE__ */ jsxs("div", { className: "flex w-16 shrink-0 flex-col items-start gap-1", children: [
3811
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-semibold tabular-nums", children: hourOf(b.startsAt) }),
3812
+ /* @__PURE__ */ jsx("span", { className: `rounded-full px-1.5 py-0.5 text-[10px] font-medium ${STATUS_TONE[b.status] ?? ""}`, children: statusLabel(b.status) })
3813
+ ] }),
3814
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
3815
+ /* @__PURE__ */ jsx("p", { className: "truncate text-sm font-medium", children: b.clientId ? /* @__PURE__ */ jsx(PersonLink, { personId: b.clientId, name: b.clientName ?? "Sem cliente", kind: "customer" }) : b.clientName ?? "Sem cliente" }),
3816
+ /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1 truncate text-xs text-muted-foreground", children: [
3817
+ /* @__PURE__ */ jsx(User, { className: "h-3 w-3", "aria-hidden": true }),
3818
+ b.professionalName ?? "Sem profissional",
3819
+ arrived && b.checkedInAt && /* @__PURE__ */ jsxs("span", { className: "ml-1 inline-flex items-center gap-0.5 text-warning", children: [
3820
+ /* @__PURE__ */ jsx(Clock, { className: "h-3 w-3", "aria-hidden": true }),
3821
+ waitedFor(b.checkedInAt)
3822
+ ] })
3823
+ ] })
3824
+ ] }),
3825
+ b.orderTotal != null && b.orderTotal > 0 && /* @__PURE__ */ jsx(
3826
+ PaymentStatusBadge,
3827
+ {
3828
+ status: b.paymentStatus ?? "none",
3829
+ totalAmount: b.orderTotal,
3830
+ formatCurrency: money,
3831
+ compact: true,
3832
+ onLoadDetails: b.orderId ? () => loadPaymentLines(b.orderId) : void 0,
3833
+ onReceive: canReceive ? () => setCharging(b) : void 0,
3834
+ onOpenDetails: () => setCharging(b)
3835
+ }
3836
+ ),
3837
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
3838
+ /* @__PURE__ */ jsx(
3839
+ "button",
3840
+ {
3841
+ type: "button",
3842
+ onClick: () => onOpenBooking ? onOpenBooking(b) : setEditing(b),
3843
+ title: "Abrir agendamento",
3844
+ "aria-label": "Abrir agendamento",
3845
+ className: "inline-flex items-center justify-center rounded-button border border-input p-1.5 hover:bg-muted",
3846
+ children: /* @__PURE__ */ jsx(CalendarDays, { className: "h-3.5 w-3.5", "aria-hidden": true })
3847
+ }
3848
+ ),
3849
+ /* @__PURE__ */ jsxs(
3850
+ "button",
3851
+ {
3852
+ type: "button",
3853
+ disabled: arrived || busyId === b.id,
3854
+ onClick: () => setReviewing(b),
3855
+ className: "inline-flex items-center gap-1.5 rounded-button bg-foreground px-3 py-1.5 text-xs font-semibold text-background disabled:opacity-40",
3856
+ children: [
3857
+ arrived ? /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5", "aria-hidden": true }) : /* @__PURE__ */ jsx(LogIn, { className: "h-3.5 w-3.5", "aria-hidden": true }),
3858
+ arrived ? "Chegou" : "Check-in"
3859
+ ]
3860
+ }
3861
+ )
3862
+ ] })
3863
+ ]
3864
+ },
3865
+ b.id
3866
+ );
3867
+ }) }) })
3868
+ ] }),
3869
+ reviewing && /* @__PURE__ */ jsx(
3870
+ CheckinDialog,
3871
+ {
3872
+ booking: reviewing,
3873
+ statusLabel: statusLabel(reviewing.status),
3874
+ requirements: requirementsFor(reviewing),
3875
+ onReceive: canReceive ? (b) => {
3876
+ setReviewing(null);
3877
+ setCharging(b);
3878
+ } : void 0,
3879
+ onConfirm: confirmCheckin,
3880
+ onClose: () => setReviewing(null)
3881
+ }
3882
+ ),
3883
+ editing && /* @__PURE__ */ jsx(
3884
+ AppointmentModal,
3885
+ {
3886
+ open: true,
3887
+ mode: "edit",
3888
+ bookingId: editing.id,
3889
+ onClose: () => {
3890
+ setEditing(null);
3891
+ void fetchBookings(todayRange(), [...CHECKIN_STATUSES]);
3892
+ }
3893
+ }
3894
+ ),
3895
+ charging?.clientId && /* @__PURE__ */ jsx(
3896
+ ClientAccountModal,
3897
+ {
3898
+ personId: charging.clientId,
3899
+ orderId: charging.orderId,
3900
+ clientName: charging.clientName,
3901
+ onGenerate: async () => {
3902
+ if (onReceive) await onReceive(charging);
3903
+ await fetchBookings(todayRange(), [...CHECKIN_STATUSES]);
3904
+ },
3905
+ onClose: () => {
3906
+ setCharging(null);
3907
+ void fetchBookings(todayRange(), [...CHECKIN_STATUSES]);
3908
+ }
3909
+ }
3910
+ )
3911
+ ] });
3912
+ }
3913
+ function CheckinPage({ config, provider: provider2, store, onReceive, onOpenBooking }) {
3914
+ const runtime = usePluginRuntimeOptional();
3915
+ const tenantId = runtime?.context.tenant?.id;
3916
+ React11.useEffect(() => {
3917
+ setAgendaTenantId(tenantId);
3918
+ return () => setAgendaTenantId(void 0);
3919
+ }, [tenantId]);
3920
+ return /* @__PURE__ */ jsx(AgendaContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsx(CheckinView, { onReceive, onOpenBooking }) });
3921
+ }
3922
+ var PEOPLE_UNITS_VIEW = "v_bookable_people";
3473
3923
  function getTenantId() {
3474
3924
  return getAgendaTenantId() ?? getActiveTenantId();
3475
3925
  }
@@ -3541,9 +3991,12 @@ async function hydrateCanonicalBookings(core, bookings) {
3541
3991
  { data: items, error: itemsError }
3542
3992
  ] = await Promise.all([
3543
3993
  personIds.length ? core.from("people").select("id, name, phone, email, avatar_url").in("id", personIds) : Promise.resolve({ data: [], error: null }),
3544
- locationIds.length ? core.from("locations").select("id, name").in("id", locationIds) : Promise.resolve({ data: [], error: null }),
3545
- orderIds.length ? core.from("orders").select("id, total, status").in("id", orderIds) : Promise.resolve({ data: [], error: null }),
3546
- orderIds.length ? core.from("order_items").select("id, order_id, service_id, name, duration_minutes, unit_price, assignee_id, sort_order").in("order_id", orderIds) : Promise.resolve({ data: [], error: null })
3994
+ locationIds.length ? core.from("v_units").select("id, name").in("id", locationIds) : Promise.resolve({ data: [], error: null }),
3995
+ // The core's read model, not the table: public.orders belongs to the core,
3996
+ // and v_orders is what it publishes (spine 184). It also carries the DERIVED
3997
+ // fulfilment state, which the table never had.
3998
+ orderIds.length ? core.from("v_orders").select("id, total, status, fulfillment_status").in("id", orderIds) : Promise.resolve({ data: [], error: null }),
3999
+ orderIds.length ? core.from("items").select("id, order_id, product_id, name, duration_minutes, unit_price, assignee_id, sort_order").in("order_id", orderIds) : Promise.resolve({ data: [], error: null })
3547
4000
  ]);
3548
4001
  if (personsError) throw personsError;
3549
4002
  if (locationsError) throw locationsError;
@@ -3560,7 +4013,7 @@ async function hydrateCanonicalBookings(core, bookings) {
3560
4013
  const order = booking.order_id ? ordersById.get(booking.order_id) : null;
3561
4014
  const bookingItems = ((booking.order_id ? itemsByOrderId.get(booking.order_id) : null) ?? []).sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map((item) => ({
3562
4015
  id: item.id,
3563
- serviceId: item.service_id,
4016
+ serviceId: item.product_id,
3564
4017
  name: item.name,
3565
4018
  durationMinutes: item.duration_minutes,
3566
4019
  price: item.unit_price,
@@ -3594,13 +4047,14 @@ async function hydrateCanonicalBookings(core, bookings) {
3594
4047
  orderStatus: order?.status ?? null,
3595
4048
  services: bookingItems.length ? bookingItems : null,
3596
4049
  totalDurationMinutes: itemDuration || getDurationMinutes(booking.starts_at, booking.ends_at),
4050
+ checkedInAt: booking.checked_in_at ?? null,
3597
4051
  createdAt: booking.created_at,
3598
4052
  updatedAt: booking.updated_at
3599
4053
  };
3600
4054
  });
3601
4055
  }
3602
4056
  async function getCanonicalBookings(core, query) {
3603
- let q = core.from("appointments").select("id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at").gte("starts_at", query.dateRange.start).lte("starts_at", query.dateRange.end);
4057
+ let q = core.from("appointments").select("id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, checked_in_at, created_at, updated_at").gte("starts_at", query.dateRange.start).lte("starts_at", query.dateRange.end);
3604
4058
  if (query.kind) q = q.eq("kind", query.kind);
3605
4059
  if (query.statuses?.length) q = q.in("status", query.statuses);
3606
4060
  if (query.professionalIds?.length) q = q.in("assignee_id", query.professionalIds);
@@ -3610,7 +4064,7 @@ async function getCanonicalBookings(core, query) {
3610
4064
  return hydrateCanonicalBookings(core, data ?? []);
3611
4065
  }
3612
4066
  async function getCanonicalBookingById(core, id) {
3613
- const columns = "id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at";
4067
+ const columns = "id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, checked_in_at, created_at, updated_at";
3614
4068
  let { data, error } = await core.from("appointments").select(columns).eq("id", id).maybeSingle();
3615
4069
  if (error) throw error;
3616
4070
  if (!data) {
@@ -3657,11 +4111,11 @@ function createSupabaseAgendaProvider(providerOptions) {
3657
4111
  if (data) {
3658
4112
  const booking = mapBooking(data);
3659
4113
  if (booking.orderId) {
3660
- const { data: items } = await core.from("order_items").select("id, service_id, name, duration_minutes, unit_price, assignee_id, sort_order").eq("order_id", booking.orderId).order("sort_order", { ascending: true });
4114
+ const { data: items } = await core.from("items").select("id, service_id, name, duration_minutes, unit_price, assignee_id, sort_order").eq("order_id", booking.orderId).order("sort_order", { ascending: true });
3661
4115
  if (items && items.length) {
3662
4116
  const services = items.map((item) => ({
3663
4117
  id: item.id,
3664
- serviceId: item.service_id,
4118
+ serviceId: item.product_id,
3665
4119
  name: item.name,
3666
4120
  durationMinutes: item.duration_minutes,
3667
4121
  price: item.unit_price,
@@ -3682,7 +4136,7 @@ function createSupabaseAgendaProvider(providerOptions) {
3682
4136
  if (!tenantId) throw new Error("No active tenant");
3683
4137
  const hasServices = input.services.length > 0;
3684
4138
  const totalDuration = input.services.reduce((sum, s) => sum + s.durationMinutes, 0);
3685
- const totalPrice = input.services.reduce((sum, s) => sum + s.price, 0);
4139
+ input.services.reduce((sum, s) => sum + s.price, 0);
3686
4140
  const endsAt = addMinutes(input.startsAt, totalDuration || 30);
3687
4141
  const serviceNames = input.services.map((s) => s.name).join(", ");
3688
4142
  let contactName;
@@ -3691,58 +4145,33 @@ function createSupabaseAgendaProvider(providerOptions) {
3691
4145
  contactName = person?.name;
3692
4146
  }
3693
4147
  const eventTitle = input.title?.trim() || void 0;
3694
- const { data: order, error: orderErr } = await core.from("orders").insert({
3695
- tenant_id: tenantId,
3696
- kind: input.kind ?? "appointment",
3697
- status: "scheduled",
3698
- party_id: input.clientId || null,
3699
- assignee_id: input.professionalId || null,
3700
- location_id: input.locationId ?? null,
3701
- subtotal: totalPrice,
3702
- total: totalPrice,
3703
- notes: input.notes ?? null,
3704
- metadata: {
3705
- source: "agenda",
3706
- ...serviceNames ? { serviceNames, itemsSummary: serviceNames } : {},
3707
- ...contactName ? { contactName } : {},
3708
- ...eventTitle ? { title: eventTitle } : {}
3709
- }
3710
- }).select("id").single();
3711
- if (orderErr) throw orderErr;
3712
- const { data: booking, error: bookingErr } = await core.from("appointments").insert({
3713
- tenant_id: tenantId,
3714
- kind: input.kind ?? "appointment",
3715
- party_id: input.clientId || null,
3716
- assignee_id: input.professionalId || null,
3717
- location_id: input.locationId ?? null,
3718
- order_id: order.id,
3719
- starts_at: input.startsAt,
3720
- ends_at: endsAt,
3721
- status: "scheduled",
3722
- notes: input.notes ?? null,
3723
- metadata: {
3724
- source: "agenda",
3725
- ...serviceNames ? { serviceNames, itemsSummary: serviceNames } : {},
3726
- ...contactName ? { contactName } : {},
3727
- ...eventTitle ? { title: eventTitle } : {}
3728
- }
3729
- }).select("id").single();
3730
- if (bookingErr) throw bookingErr;
3731
- if (hasServices) {
3732
- const orderItems = input.services.map((s, i) => ({
3733
- order_id: order.id,
4148
+ const metadata = {
4149
+ source: "agenda",
4150
+ ...serviceNames ? { serviceNames, itemsSummary: serviceNames } : {},
4151
+ ...contactName ? { contactName } : {},
4152
+ ...eventTitle ? { title: eventTitle } : {}
4153
+ };
4154
+ const { data: created, error: rpcErr } = await core.rpc("agenda_booking_create", {
4155
+ p_starts_at: input.startsAt,
4156
+ p_ends_at: endsAt,
4157
+ p_lines: hasServices ? input.services.map((s, i) => ({
3734
4158
  service_id: s.serviceId,
3735
4159
  name: s.name,
3736
4160
  quantity: 1,
3737
4161
  unit_price: s.price,
3738
- total: s.price,
3739
4162
  sort_order: i,
3740
4163
  duration_minutes: s.durationMinutes,
3741
4164
  assignee_id: s.assigneeId || input.professionalId || null
3742
- }));
3743
- const { error: oiErr } = await core.from("order_items").insert(orderItems);
3744
- if (oiErr) throw oiErr;
3745
- }
4165
+ })) : [],
4166
+ p_party_id: input.clientId || null,
4167
+ p_assignee_id: input.professionalId || null,
4168
+ p_location_id: input.locationId ?? null,
4169
+ p_kind: input.kind ?? "appointment",
4170
+ p_notes: input.notes ?? null,
4171
+ p_metadata: metadata
4172
+ });
4173
+ if (rpcErr) throw rpcErr;
4174
+ const booking = { id: created.appointment_id };
3746
4175
  const createdBooking = await this.getBookingById(booking.id);
3747
4176
  if (!createdBooking) throw new Error("Created booking did not resolve from agenda read model");
3748
4177
  return createdBooking;
@@ -3793,7 +4222,7 @@ function createSupabaseAgendaProvider(providerOptions) {
3793
4222
  if (orderErr) throw orderErr;
3794
4223
  }
3795
4224
  if (data.services) {
3796
- await core.from("order_items").delete().eq("order_id", orderId);
4225
+ await core.from("items").delete().eq("order_id", orderId);
3797
4226
  if (data.services.length > 0) {
3798
4227
  const items = data.services.map((svc, idx) => ({
3799
4228
  order_id: orderId,
@@ -3806,7 +4235,7 @@ function createSupabaseAgendaProvider(providerOptions) {
3806
4235
  duration_minutes: svc.durationMinutes,
3807
4236
  assignee_id: svc.assigneeId ?? effectiveAssigneeId
3808
4237
  }));
3809
- const { error: itemErr } = await core.from("order_items").insert(items);
4238
+ const { error: itemErr } = await core.from("items").insert(items);
3810
4239
  if (itemErr) throw itemErr;
3811
4240
  }
3812
4241
  }
@@ -3898,12 +4327,22 @@ function createSupabaseAgendaProvider(providerOptions) {
3898
4327
  const { error } = await core.from("schedules").delete().eq("id", id);
3899
4328
  if (error) throw error;
3900
4329
  },
3901
- async getProfessionals() {
4330
+ async getProfessionals(query) {
3902
4331
  const { core } = getClients();
3903
- const base = () => core.from("people").select("id, name, avatar_url, is_active").eq("kind", professionalKind).eq("is_active", true);
4332
+ const unitId = query?.unitId ?? getActiveUnitId();
4333
+ const base = () => {
4334
+ let qb = core.from(PEOPLE_UNITS_VIEW).select("id, name, avatar_url, is_active, unit_ids").eq("kind", professionalKind).eq("is_active", true);
4335
+ if (unitId) qb = qb.or(`unit_ids.is.null,unit_ids.cs.{${unitId}}`);
4336
+ if (query?.search) qb = qb.ilike("name", `%${query.search}%`);
4337
+ if (query?.limit) qb = qb.limit(query.limit);
4338
+ return qb;
4339
+ };
3904
4340
  let { data, error } = await base().eq("is_bookable", true).order("name");
3905
- if (error && error.code === "42703") {
3906
- ({ data, error } = await base().order("name"));
4341
+ if (error && (error.code === "42703" || error.code === "42P01")) {
4342
+ let qb = core.from("people").select("id, name, avatar_url, is_active").eq("kind", professionalKind).eq("is_active", true);
4343
+ if (query?.search) qb = qb.ilike("name", `%${query.search}%`);
4344
+ if (query?.limit) qb = qb.limit(query.limit);
4345
+ ({ data, error } = await qb.order("name"));
3907
4346
  }
3908
4347
  if (error) throw error;
3909
4348
  return (data ?? []).map((p) => ({
@@ -3917,9 +4356,12 @@ function createSupabaseAgendaProvider(providerOptions) {
3917
4356
  },
3918
4357
  async getLocations() {
3919
4358
  const { core } = getClients();
3920
- const { data, error } = await core.from("locations").select("id, name").eq("is_active", true).order("name");
4359
+ const unitId = getActiveUnitId();
4360
+ let qb = core.from("v_units").select("id, name, kind").eq("is_active", true);
4361
+ if (unitId) qb = qb.eq("id", unitId);
4362
+ const { data, error } = await qb.order("name");
3921
4363
  if (error) throw error;
3922
- return (data ?? []).map((l) => ({ id: l.id, name: l.name }));
4364
+ return (data ?? []).filter((l) => l.kind !== "region").map((l) => ({ id: l.id, name: l.name }));
3923
4365
  },
3924
4366
  async getConfirmationsPending(daysAhead = 2) {
3925
4367
  const { pub } = getClients();
@@ -3984,6 +4426,7 @@ function emitBookingEvent(key, booking) {
3984
4426
  }
3985
4427
 
3986
4428
  // src/store.ts
4429
+ var PROFESSIONAL_LIMIT = 200;
3987
4430
  function getCurrentBookingQuery(state, dateRange = state.dateRange) {
3988
4431
  return {
3989
4432
  dateRange,
@@ -4001,6 +4444,7 @@ function createAgendaStore(provider2, financialBridge) {
4001
4444
  selectedDate: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
4002
4445
  professionals: [],
4003
4446
  professionalsLoading: false,
4447
+ professionalsTruncated: false,
4004
4448
  selectedProfessionalIds: [],
4005
4449
  selectedLocationId: null,
4006
4450
  selectedStatuses: ["scheduled", "confirmed", "in_progress", "completed"],
@@ -4010,21 +4454,21 @@ function createAgendaStore(provider2, financialBridge) {
4010
4454
  pendingConfirmations: [],
4011
4455
  confirmationsLoading: false,
4012
4456
  appointmentModal: { open: false, mode: "create" },
4013
- async fetchBookings(range) {
4014
- const key = `agenda:bookings:${range.start}:${range.end}`;
4457
+ async fetchBookings(range, statuses) {
4458
+ const key = `agenda:bookings:${range.start}:${range.end}:${statuses?.join(",") ?? ""}`;
4015
4459
  return dedup(key, async () => {
4016
4460
  set({ bookingsLoading: true, dateRange: range });
4017
4461
  const state = get();
4018
- const query = getCurrentBookingQuery(state, range);
4462
+ const query = statuses?.length ? { ...getCurrentBookingQuery(state, range), statuses } : getCurrentBookingQuery(state, range);
4019
4463
  let bookings = await provider2.getBookings(query);
4020
4464
  if (financialBridge) {
4021
4465
  const orderIds = bookings.map((b) => b.orderId).filter((id) => !!id);
4022
4466
  if (orderIds.length > 0) {
4023
4467
  try {
4024
- const statuses = await financialBridge.resolvePaymentStatuses(orderIds);
4468
+ const statuses2 = await financialBridge.resolvePaymentStatuses(orderIds);
4025
4469
  bookings = bookings.map((b) => {
4026
4470
  if (!b.orderId) return b;
4027
- const summary = statuses.get(b.orderId);
4471
+ const summary = statuses2.get(b.orderId);
4028
4472
  return summary ? { ...b, paymentStatus: summary.status } : b;
4029
4473
  });
4030
4474
  } catch {
@@ -4034,11 +4478,17 @@ function createAgendaStore(provider2, financialBridge) {
4034
4478
  set({ bookings, bookingsLoading: false });
4035
4479
  });
4036
4480
  },
4037
- async fetchProfessionals() {
4038
- return dedup("agenda:professionals", async () => {
4481
+ async fetchProfessionals(query) {
4482
+ const limit = query?.limit ?? PROFESSIONAL_LIMIT;
4483
+ const effective = { ...query, limit };
4484
+ return dedup("agenda:professionals:" + JSON.stringify(effective), async () => {
4039
4485
  set({ professionalsLoading: true });
4040
- const professionals = await provider2.getProfessionals();
4041
- set({ professionals, professionalsLoading: false });
4486
+ const professionals = await provider2.getProfessionals(effective);
4487
+ set({
4488
+ professionals,
4489
+ professionalsTruncated: professionals.length >= limit,
4490
+ professionalsLoading: false
4491
+ });
4042
4492
  });
4043
4493
  },
4044
4494
  async fetchLocations() {
@@ -4461,6 +4911,8 @@ var en = {
4461
4911
  "agenda.settings.workingHours": "Working Hours",
4462
4912
  "agenda.settings.workingHoursDesc": "Working hours and availability",
4463
4913
  "agenda.sidebar.agendas": "Agendas",
4914
+ "agenda.sidebar.searchAgendas": "Find an agenda",
4915
+ "agenda.sidebar.tooManyAgendas": "Up to {max} agendas at a time \u2014 uncheck one to add another.",
4464
4916
  "agenda.sidebar.allLocations": "All locations",
4465
4917
  "agenda.sidebar.create": "Create",
4466
4918
  "agenda.sidebar.location": "Location",
@@ -4683,6 +5135,8 @@ var ptBR = {
4683
5135
  "agenda.settings.workingHours": "Hor\xE1rio de Funcionamento",
4684
5136
  "agenda.settings.workingHoursDesc": "Hor\xE1rios e disponibilidade",
4685
5137
  "agenda.sidebar.agendas": "Agendas",
5138
+ "agenda.sidebar.searchAgendas": "Buscar agenda",
5139
+ "agenda.sidebar.tooManyAgendas": "At\xE9 {max} agendas por vez \u2014 desmarque uma para incluir outra.",
4686
5140
  "agenda.sidebar.allLocations": "Todos os locais",
4687
5141
  "agenda.sidebar.create": "Criar",
4688
5142
  "agenda.sidebar.location": "Local",
@@ -4756,199 +5210,659 @@ var agendaLocales = {
4756
5210
  };
4757
5211
 
4758
5212
  // src/migrations/index.ts
4759
- var MIGRATION_001_PUBLIC_BOOKING = `-- ============================================================================
4760
- -- plugin-agenda 001: booking read model + public (anon) booking surface
5213
+ var MIGRATION_000_BASELINE = `-- ============================================================================
5214
+ -- plugins/plugin-agenda/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.
4761
5215
  --
4762
- -- Ships the objects the agenda providers reference but the spine (packages/db
4763
- -- migrations 001\u2013010) does not define:
4764
- -- \xA70 converted-pool cleanup \u2014 drop the pre-rename v_bookings view / indexes
4765
- -- \xA71 order_items columns the admin provider writes (duration/assignee)
4766
- -- \xA72 public.v_appointments \u2014 admin read model (authenticated, RLS applies)
4767
- -- \xA73 public.v_public_services \u2014 anon-readable service catalog
4768
- -- \xA74 public.get_available_slots \u2014 anon RPC (mirrors the mock algorithm)
4769
- -- \xA75 public.create_public_booking \u2014 anon RPC (validated, race-safe write)
5216
+ -- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:
5217
+ -- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs
5218
+ -- prove the result still matches.
4770
5219
  --
4771
- -- Core v1 lives directly in PUBLIC (no separate core schema): people (was
4772
- -- persons), appointments (was bookings). This migration is written against public
4773
- -- names and is idempotent + safe on both fresh and converted pools.
5220
+ -- This replaced 23 migration files. They are kept, unapplied, in
5221
+ -- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no
5222
+ -- longer worth replaying.
4774
5223
  --
4775
- -- Anon-access design: public booking pages run with the publishable key and no
4776
- -- session, so the canonical tenant RLS yields nothing. Anon access is granted
4777
- -- ONLY via the column-whitelisted view (\xA73) and SECURITY DEFINER RPCs (\xA74, \xA75)
4778
- -- with tight input validation and hardcoded statuses.
5224
+ -- Object order is fixed rather than dependency-sorted: schemas, types,
5225
+ -- sequences, tables, defaults, functions, constraints, views, indexes, foreign
5226
+ -- keys, triggers, row security, policies, grants. Dependencies BETWEEN units
5227
+ -- are carried by the order packages/db/chain.json declares.
4779
5228
  -- ============================================================================
4780
5229
 
4781
- -- \xA70 \u2014 converted-pool cleanup. The pre-industry-pool shape shipped a v_bookings
4782
- -- view and persons_/bookings_-named indexes; drop them so this file re-emits the
4783
- -- canonical v_appointments view + people_/appointments_ indexes without leaving
4784
- -- duplicates. All guarded with IF EXISTS \u2192 no-ops on a fresh pool.
4785
- DROP VIEW IF EXISTS public.v_bookings;
4786
- DROP INDEX IF EXISTS public.persons_tenant_customer_phone;
4787
- DROP INDEX IF EXISTS public.bookings_assignee_time;
4788
-
4789
- -- \xA71 \u2014 columns the agenda provider writes but 004_archetypes lacks
4790
- ALTER TABLE public.order_items
4791
- ADD COLUMN IF NOT EXISTS duration_minutes integer,
4792
- ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
4793
-
4794
- -- Lookup key for public customers (per tenant, by normalized phone).
4795
- -- NOT UNIQUE. It was, and spine/110 says why it cannot be: \`kind\` became a
4796
- -- derived column, so an imported 'client' turns into a 'customer' and a
4797
- -- placeholder phone shared by eight real people lands under one key. The upsert
4798
- -- that used to lean on it takes a per-(tenant, phone) advisory lock instead
4799
- -- (003, 009) \u2014 the index is here for the lookup that lock guards.
4800
- DROP INDEX IF EXISTS public.people_tenant_customer_phone;
4801
- CREATE INDEX IF NOT EXISTS people_tenant_customer_phone
4802
- ON public.people(tenant_id, phone)
4803
- WHERE kind = 'customer' AND phone IS NOT NULL;
4804
-
4805
- -- Slot-conflict scan
4806
- CREATE INDEX IF NOT EXISTS appointments_assignee_time
4807
- ON public.appointments(assignee_id, starts_at, ends_at)
4808
- WHERE status NOT IN ('cancelled', 'no_show');
4809
-
4810
- -- \xA72 \u2014 admin read model (security_invoker \u2192 underlying RLS applies; anon sees 0 rows)
4811
- -- DROP first: 013 widens this view and CREATE OR REPLACE cannot narrow a live
4812
- -- view on replay ("cannot drop columns from view"). 013 replays right after.
4813
- -- 013 builds on this one, so the drop needs its dependents out and back (143).
4814
- SELECT app.detach_view_dependents('public.v_appointments');
4815
- DROP VIEW IF EXISTS public.v_appointments;
4816
- CREATE VIEW public.v_appointments
4817
- WITH (security_invoker = true) AS
4818
- SELECT
4819
- b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
4820
- b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,
4821
- b.party_id AS client_id,
4822
- COALESCE(pc.name, b.metadata->>'title') AS client_name,
4823
- pc.phone AS client_phone,
4824
- pc.email AS client_email,
4825
- pc.avatar_url AS client_avatar_url,
4826
- b.assignee_id AS professional_id,
4827
- ps.name AS professional_name,
4828
- ps.avatar_url AS professional_avatar_url,
4829
- l.name AS location_name,
4830
- o.total AS order_total,
4831
- o.status AS order_status,
4832
- (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
4833
- AS total_duration_minutes
4834
- FROM public.appointments b
4835
- LEFT JOIN public.people pc ON pc.id = b.party_id
4836
- LEFT JOIN public.people ps ON ps.id = b.assignee_id
4837
- LEFT JOIN public.locations l ON l.id = b.location_id
4838
- LEFT JOIN public.orders o ON o.id = b.order_id;
4839
-
4840
- GRANT SELECT ON public.v_appointments TO authenticated, service_role;
4841
- REVOKE SELECT ON public.v_appointments FROM anon;
4842
-
4843
- -- \xA73 \u2014 anon-readable public catalog (owner-rights view = deliberate, whitelisted)
4844
- -- DROP first: 010 widens this view (curation columns) \u2014 same replay rule as \xA72.
4845
- DROP VIEW IF EXISTS public.v_public_services;
4846
- CREATE VIEW public.v_public_services AS
4847
- SELECT
4848
- s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
4849
- COALESCE(s.duration_minutes, 30) AS duration_minutes,
4850
- s.image_url,
4851
- COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
4852
- FROM public.services s
4853
- WHERE s.is_active AND s.status = 'active';
5230
+ -- A LANGUAGE sql function binds its body at CREATE time, and 570 functions
5231
+ -- sorted by name are not sorted by who calls whom. This is what lets them load
5232
+ -- in any order; every body is still compiled on first call, so a genuinely
5233
+ -- broken reference surfaces then rather than never. Session-scoped.
5234
+ SET check_function_bodies = false;
5235
+
5236
+
5237
+
5238
+ -- \u2500\u2500 default privileges: reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5239
+ -- Cleared BEFORE anything is created, so every object below gets the ACL the
5240
+ -- dump describes rather than the image's defaults on top of it. Restored at
5241
+ -- the end of this file.
5242
+
5243
+ DO $dp$ BEGIN
5244
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5245
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
5246
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5247
+ END $dp$;
5248
+
5249
+ DO $dp$ BEGIN
5250
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5251
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
5252
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5253
+ END $dp$;
5254
+
5255
+ DO $dp$ BEGIN
5256
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
5257
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
5258
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
5259
+ END $dp$;
5260
+
5261
+ DO $dp$ BEGIN
5262
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
5263
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
5264
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
5265
+ END $dp$;
5266
+
5267
+ DO $dp$ BEGIN
5268
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5269
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
5270
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5271
+ END $dp$;
5272
+
5273
+ DO $dp$ BEGIN
5274
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5275
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
5276
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
5277
+ END $dp$;
5278
+
5279
+
5280
+ -- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5281
+
5282
+ CREATE FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) RETURNS void
5283
+ LANGUAGE plpgsql SECURITY DEFINER
5284
+ SET search_path TO ''
5285
+ AS $$
5286
+ BEGIN
5287
+ IF EXISTS (SELECT 1 FROM app.resource_grants g
5288
+ WHERE g.tenant_id = p_tenant_id
5289
+ AND g.resource_type = 'catalog.product'
5290
+ AND g.resource_id = p_service_id
5291
+ AND g.relation = 'available_in') THEN
5292
+ RAISE EXCEPTION 'este servi\xE7o n\xE3o est\xE1 dispon\xEDvel para agendamento online'
5293
+ USING ERRCODE = '42501',
5294
+ HINT = 'The service is released to specific units; the public booking path is tenant-wide and cannot honour a unit release (#201).';
5295
+ END IF;
5296
+ END $$;
5297
+
5298
+ CREATE FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean DEFAULT false) RETURNS jsonb
5299
+ LANGUAGE plpgsql SECURITY DEFINER
5300
+ SET search_path TO ''
5301
+ AS $_$
5302
+ DECLARE
5303
+ v_tenant uuid := app.current_tenant_id();
5304
+ v_appt public.appointments%ROWTYPE;
5305
+ v_money integer := 0;
5306
+ BEGIN
5307
+ SELECT * INTO v_appt FROM public.appointments a WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;
5308
+ IF v_appt.id IS NULL THEN
5309
+ RAISE EXCEPTION 'agenda_booking_cancel: appointment % not found', p_appointment_id USING ERRCODE = 'P0002';
5310
+ END IF;
5311
+ IF NOT (select app.has_permission('agenda.delete', v_appt.unit_id)) THEN
5312
+ RAISE EXCEPTION 'agenda_booking_cancel: agenda.delete is required' USING ERRCODE = '42501';
5313
+ END IF;
4854
5314
 
4855
- GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
5315
+ -- Money that exists is never deleted. A booking that raised a T\xEDtulo is
5316
+ -- CANCELLED, both sides, in one transaction.
5317
+ IF v_appt.order_id IS NOT NULL AND to_regclass('public.plg_financial_invoices') IS NOT NULL THEN
5318
+ EXECUTE 'SELECT count(*)::int FROM public.plg_financial_invoices WHERE order_id = $1 AND cancelled_at IS NULL'
5319
+ INTO v_money USING v_appt.order_id;
5320
+ END IF;
4856
5321
 
4857
- -- \xA74 \u2014 availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)
4858
- CREATE OR REPLACE FUNCTION public.get_available_slots(
4859
- p_tenant_id uuid,
4860
- p_date date,
4861
- p_duration_minutes integer,
4862
- p_assignee_id uuid DEFAULT NULL,
4863
- p_slot_interval integer DEFAULT 30
4864
- ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
4865
- LANGUAGE plpgsql STABLE SECURITY DEFINER
4866
- SET search_path = public
4867
- AS $$
5322
+ IF v_money > 0 OR NOT p_hard THEN
5323
+ UPDATE public.appointments SET status = 'cancelled' WHERE id = p_appointment_id;
5324
+ IF v_appt.order_id IS NOT NULL THEN
5325
+ UPDATE public.orders SET status = 'cancelled' WHERE id = v_appt.order_id AND status <> 'completed';
5326
+ END IF;
5327
+ RETURN jsonb_build_object('cancelled', true, 'deleted', false, 'order_id', v_appt.order_id);
5328
+ END IF;
5329
+
5330
+ DELETE FROM public.appointments WHERE id = p_appointment_id;
5331
+ IF v_appt.order_id IS NOT NULL THEN
5332
+ DELETE FROM public.orders WHERE id = v_appt.order_id;
5333
+ END IF;
5334
+ RETURN jsonb_build_object('cancelled', true, 'deleted', true, 'order_id', v_appt.order_id);
5335
+ END $_$;
5336
+
5337
+ CREATE FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_lines jsonb DEFAULT '[]'::jsonb, p_party_id uuid DEFAULT NULL::uuid, p_assignee_id uuid DEFAULT NULL::uuid, p_unit_id uuid DEFAULT NULL::uuid, p_location_id uuid DEFAULT NULL::uuid, p_kind text DEFAULT 'appointment'::text, p_notes text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb
5338
+ LANGUAGE plpgsql SECURITY DEFINER
5339
+ SET search_path TO ''
5340
+ AS $$
4868
5341
  DECLARE
4869
- v_tz text;
5342
+ v_tenant uuid := app.current_tenant_id();
5343
+ v_order uuid;
5344
+ v_booking uuid;
5345
+ v_ends timestamptz;
5346
+ v_minutes integer;
5347
+ v_line jsonb;
4870
5348
  BEGIN
4871
- -- tight validation: this function is anon-callable
4872
- IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
4873
- RAISE EXCEPTION 'invalid duration';
5349
+ IF v_tenant IS NULL THEN
5350
+ RAISE EXCEPTION 'agenda_booking_create: no active tenant' USING ERRCODE = '42501';
4874
5351
  END IF;
4875
- IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
4876
- RAISE EXCEPTION 'invalid interval';
5352
+ IF NOT (select app.has_permission('agenda.create', p_unit_id)) THEN
5353
+ RAISE EXCEPTION 'agenda_booking_create: agenda.create is required' USING ERRCODE = '42501';
4877
5354
  END IF;
4878
- IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
4879
- RETURN;
5355
+
5356
+ IF p_idempotency_key IS NOT NULL THEN
5357
+ SELECT a.id, a.order_id INTO v_booking, v_order FROM public.appointments a
5358
+ WHERE a.tenant_id = v_tenant AND a.metadata ->> 'idempotencyKey' = p_idempotency_key
5359
+ LIMIT 1;
5360
+ IF v_booking IS NOT NULL THEN
5361
+ RETURN jsonb_build_object('appointment_id', v_booking, 'order_id', v_order, 'replayed', true);
5362
+ END IF;
4880
5363
  END IF;
4881
5364
 
4882
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
4883
- FROM tenants t WHERE t.id = p_tenant_id;
4884
- IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant \u2192 empty, not an error
5365
+ SELECT coalesce(sum(coalesce((l ->> 'duration_minutes')::integer, 0)), 0)
5366
+ INTO v_minutes FROM jsonb_array_elements(coalesce(p_lines, '[]'::jsonb)) l;
5367
+ v_ends := coalesce(p_ends_at, p_starts_at + (greatest(v_minutes, 30) || ' minutes')::interval);
5368
+
5369
+ -- The core's door, not a direct write. One statement, and the lines come with
5370
+ -- it: there is no window in which the Venda exists without them.
5371
+ v_order := public.order_create(
5372
+ p_kind => p_kind,
5373
+ p_lines => coalesce(p_lines, '[]'::jsonb),
5374
+ p_party_id => p_party_id,
5375
+ p_unit_id => p_unit_id,
5376
+ p_status => 'scheduled',
5377
+ p_assignee_id => p_assignee_id,
5378
+ p_notes => p_notes,
5379
+ p_metadata => coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'agenda'),
5380
+ p_idempotency_key => p_idempotency_key);
5381
+
5382
+ INSERT INTO public.appointments (tenant_id, unit_id, kind, party_id, assignee_id, location_id,
5383
+ order_id, starts_at, ends_at, status, notes, metadata)
5384
+ VALUES (v_tenant, p_unit_id, p_kind, p_party_id, p_assignee_id, p_location_id,
5385
+ v_order, p_starts_at, v_ends, 'scheduled', p_notes,
5386
+ coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'agenda')
5387
+ || CASE WHEN p_idempotency_key IS NULL THEN '{}'::jsonb
5388
+ ELSE jsonb_build_object('idempotencyKey', p_idempotency_key) END)
5389
+ RETURNING id INTO v_booking;
4885
5390
 
4886
- RETURN QUERY
4887
- WITH day_schedules AS (
4888
- SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
4889
- FROM schedules sc
4890
- WHERE sc.tenant_id = p_tenant_id
4891
- AND sc.kind = 'working_hours'
4892
- AND sc.is_active
4893
- AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
4894
- AND (
4895
- sc.specific_date = p_date
4896
- OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
4897
- )
4898
- ),
4899
- candidate AS (
4900
- SELECT
4901
- ds.assignee_id,
4902
- gs AS s_start,
4903
- gs + make_interval(mins => p_duration_minutes) AS s_end
4904
- FROM day_schedules ds
4905
- CROSS JOIN LATERAL generate_series(
4906
- (p_date + ds.win_start) AT TIME ZONE v_tz,
4907
- ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
4908
- make_interval(mins => p_slot_interval)
4909
- ) AS gs
5391
+ -- The agenda's own per-line columns (001 added them to the line table): who
5392
+ -- performs it and how long it takes.
5393
+ FOR v_line IN SELECT * FROM jsonb_array_elements(coalesce(p_lines, '[]'::jsonb)) LOOP
5394
+ UPDATE public.items oi
5395
+ SET duration_minutes = nullif(v_line ->> 'duration_minutes', '')::integer,
5396
+ assignee_id = coalesce(nullif(v_line ->> 'assignee_id', '')::uuid, p_assignee_id)
5397
+ WHERE oi.order_id = v_order
5398
+ AND oi.product_id IS NOT DISTINCT FROM nullif(v_line ->> 'service_id', '')::uuid
5399
+ AND oi.name = coalesce(nullif(btrim(coalesce(v_line ->> 'name', '')), ''), 'Item');
5400
+ END LOOP;
5401
+
5402
+ RETURN jsonb_build_object('appointment_id', v_booking, 'order_id', v_order, 'replayed', false);
5403
+ END $$;
5404
+
5405
+ CREATE FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer DEFAULT 1, p_due_date date DEFAULT NULL::date) RETURNS jsonb
5406
+ LANGUAGE plpgsql SECURITY DEFINER
5407
+ SET search_path TO ''
5408
+ AS $$
5409
+ DECLARE
5410
+ v_tenant uuid := app.current_tenant_id();
5411
+ v_order uuid;
5412
+ BEGIN
5413
+ SELECT a.order_id INTO v_order FROM public.appointments a
5414
+ WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;
5415
+ IF v_order IS NULL THEN
5416
+ RAISE EXCEPTION 'agenda_booking_invoice: appointment % has no Venda to bill', p_appointment_id
5417
+ USING ERRCODE = 'P0002',
5418
+ HINT = 'A booking created through agenda_booking_create always carries one; v_appointments_sem_order lists the rest.';
5419
+ END IF;
5420
+
5421
+ IF to_regprocedure('public.fn_invoice_from_order(uuid, date, text, integer, text)') IS NULL THEN
5422
+ RAISE EXCEPTION 'agenda_booking_invoice: the financial plugin is not installed' USING ERRCODE = '0A000';
5423
+ END IF;
5424
+
5425
+ PERFORM public.fn_invoice_from_order(v_order, p_due_date, 'invoiced', greatest(coalesce(p_installments, 1), 1), 'credit');
5426
+ RETURN jsonb_build_object('order_id', v_order, 'appointment_id', p_appointment_id);
5427
+ END $$;
5428
+
5429
+ CREATE FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_ends_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_status text DEFAULT NULL::text, p_party_id uuid DEFAULT NULL::uuid, p_assignee_id uuid DEFAULT NULL::uuid, p_location_id uuid DEFAULT NULL::uuid, p_notes text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb, p_lines jsonb DEFAULT NULL::jsonb) RETURNS jsonb
5430
+ LANGUAGE plpgsql SECURITY DEFINER
5431
+ SET search_path TO ''
5432
+ AS $$
5433
+ DECLARE
5434
+ v_tenant uuid := app.current_tenant_id();
5435
+ v_appt public.appointments%ROWTYPE;
5436
+ v_line jsonb;
5437
+ v_n integer := 0;
5438
+ v_sub numeric := 0;
5439
+ v_qty numeric;
5440
+ v_price numeric;
5441
+ BEGIN
5442
+ SELECT * INTO v_appt FROM public.appointments a WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;
5443
+ IF v_appt.id IS NULL THEN
5444
+ RAISE EXCEPTION 'agenda_booking_update: appointment % not found', p_appointment_id USING ERRCODE = 'P0002';
5445
+ END IF;
5446
+ IF NOT (select app.has_permission('agenda.edit', v_appt.unit_id)) THEN
5447
+ RAISE EXCEPTION 'agenda_booking_update: agenda.edit is required' USING ERRCODE = '42501';
5448
+ END IF;
5449
+
5450
+ UPDATE public.appointments SET
5451
+ starts_at = coalesce(p_starts_at, starts_at),
5452
+ ends_at = coalesce(p_ends_at, ends_at),
5453
+ status = coalesce(p_status, status),
5454
+ party_id = coalesce(p_party_id, party_id),
5455
+ assignee_id = coalesce(p_assignee_id, assignee_id),
5456
+ location_id = coalesce(p_location_id, location_id),
5457
+ notes = coalesce(p_notes, notes),
5458
+ metadata = coalesce(metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb)
5459
+ WHERE id = p_appointment_id;
5460
+
5461
+ IF v_appt.order_id IS NOT NULL THEN
5462
+ UPDATE public.orders SET
5463
+ party_id = coalesce(p_party_id, party_id),
5464
+ assignee_id = coalesce(p_assignee_id, assignee_id),
5465
+ location_id = coalesce(p_location_id, location_id),
5466
+ notes = coalesce(p_notes, notes)
5467
+ WHERE id = v_appt.order_id;
5468
+
5469
+ IF p_lines IS NOT NULL THEN
5470
+ -- Replacing the lines and the header total in the SAME statement pair is
5471
+ -- the point: a total that disagrees with its lines is what two browser
5472
+ -- calls left behind whenever the second one did not land.
5473
+ DELETE FROM public.items WHERE order_id = v_appt.order_id;
5474
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP
5475
+ v_n := v_n + 1;
5476
+ v_qty := coalesce((v_line ->> 'quantity')::numeric, 1);
5477
+ v_price := round(coalesce((v_line ->> 'unit_price')::numeric, 0), 2);
5478
+ INSERT INTO public.items (order_id, product_id, name, quantity, unit_price, total,
5479
+ sort_order, duration_minutes, assignee_id)
5480
+ VALUES (v_appt.order_id, nullif(v_line ->> 'service_id', '')::uuid,
5481
+ coalesce(nullif(btrim(coalesce(v_line ->> 'name', '')), ''), 'Item'),
5482
+ v_qty, v_price, round(v_qty * v_price, 2),
5483
+ coalesce((v_line ->> 'sort_order')::integer, v_n - 1),
5484
+ nullif(v_line ->> 'duration_minutes', '')::integer,
5485
+ coalesce(nullif(v_line ->> 'assignee_id', '')::uuid, p_assignee_id, v_appt.assignee_id));
5486
+ v_sub := v_sub + round(v_qty * v_price, 2);
5487
+ END LOOP;
5488
+ UPDATE public.orders SET subtotal = v_sub, total = v_sub WHERE id = v_appt.order_id;
5489
+ END IF;
5490
+ END IF;
5491
+
5492
+ RETURN jsonb_build_object('appointment_id', p_appointment_id, 'order_id', v_appt.order_id);
5493
+ END $$;
5494
+
5495
+ CREATE FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid DEFAULT NULL::uuid) RETURNS TABLE(assignee_id uuid, win_start time without time zone, win_end time without time zone)
5496
+ LANGUAGE sql STABLE
5497
+ SET search_path TO 'public'
5498
+ AS $$
5499
+ WITH exc AS (
5500
+ -- Every specific-date row for the date, ACTIVE OR NOT: the inactive day-off
5501
+ -- sentinel must still suppress the weekly rows below.
5502
+ SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active
5503
+ FROM schedules sc
5504
+ WHERE sc.tenant_id = p_tenant_id
5505
+ AND sc.kind = 'working_hours'
5506
+ AND sc.specific_date = p_date
5507
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
4910
5508
  )
4911
- SELECT DISTINCT c.s_start, c.s_end
4912
- FROM candidate c
4913
- WHERE NOT EXISTS (
5509
+ SELECT e.assignee_id, e.starts_at, e.ends_at
5510
+ FROM exc e
5511
+ WHERE e.is_active
5512
+ UNION ALL
5513
+ SELECT sc.assignee_id, sc.starts_at, sc.ends_at
5514
+ FROM schedules sc
5515
+ WHERE sc.tenant_id = p_tenant_id
5516
+ AND sc.kind = 'working_hours'
5517
+ AND sc.is_active
5518
+ AND sc.specific_date IS NULL
5519
+ AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint
5520
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
5521
+ AND NOT EXISTS (SELECT 1 FROM exc e WHERE e.assignee_id = sc.assignee_id)
5522
+ $$;
5523
+
5524
+ CREATE FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb
5525
+ LANGUAGE plpgsql SECURITY DEFINER
5526
+ SET search_path TO 'public'
5527
+ AS $_$
5528
+ DECLARE
5529
+ v_denial jsonb;
5530
+ v_used int;
5531
+ v_client_id uuid;
5532
+ v_service_id uuid;
5533
+ v_professional uuid;
5534
+ v_starts timestamptz;
5535
+ v_starts_raw text;
5536
+ v_notes text;
5537
+ v_service record;
5538
+ v_client record;
5539
+ v_ends timestamptz;
5540
+ v_tz text;
5541
+ v_order uuid;
5542
+ v_booking uuid;
5543
+ BEGIN
5544
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
5545
+ FROM tenants t WHERE t.id = p_tenant_id;
5546
+ IF v_tz IS NULL THEN
5547
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
5548
+ END IF;
5549
+
5550
+ -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5551
+ BEGIN
5552
+ v_client_id := (p_payload->>'client_id')::uuid;
5553
+ v_service_id := (p_payload->>'service_id')::uuid;
5554
+ v_professional := (p_payload->>'professional_id')::uuid;
5555
+ v_starts_raw := p_payload->>'starts_at';
5556
+ IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
5557
+ v_starts := v_starts_raw::timestamptz; -- explicit offset
5558
+ ELSE
5559
+ v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
5560
+ END IF;
5561
+ EXCEPTION WHEN OTHERS THEN
5562
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
5563
+ END;
5564
+ v_notes := left(p_payload->>'notes', 500);
5565
+ IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
5566
+ RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
5567
+ END IF;
5568
+ IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
5569
+ RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
5570
+ END IF;
5571
+
5572
+ -- \u2500\u2500 authorization: role \u2192 plan \u2192 bookings_month cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5573
+ SELECT count(*) INTO v_used FROM appointments b
5574
+ WHERE b.tenant_id = p_tenant_id
5575
+ AND b.created_at >= date_trunc('month', now());
5576
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
5577
+ 'appointments', 'create', 'bookings_month', v_used, 1);
5578
+ IF v_denial IS NOT NULL THEN
5579
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
5580
+ END IF;
5581
+
5582
+ -- \u2500\u2500 invariants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5583
+ SELECT p.id, p.name INTO v_client
5584
+ FROM people p
5585
+ WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
5586
+ IF NOT FOUND THEN
5587
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
5588
+ END IF;
5589
+
5590
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
5591
+ INTO v_service
5592
+ FROM services s
5593
+ WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
5594
+ AND s.is_active AND s.status = 'active';
5595
+ IF NOT FOUND THEN
5596
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
5597
+ END IF;
5598
+
5599
+ v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
5600
+
5601
+ -- exception-aware windows (007); still prefer a professional who is FREE at
5602
+ -- the slot when the user did not name one
5603
+ SELECT w.assignee_id INTO v_professional
5604
+ FROM agenda_working_windows(
5605
+ p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
5606
+ WHERE (v_starts AT TIME ZONE v_tz)::time >= w.win_start
5607
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
5608
+ AND NOT EXISTS (
5609
+ SELECT 1 FROM appointments b
5610
+ WHERE b.tenant_id = p_tenant_id
5611
+ AND b.assignee_id = w.assignee_id
5612
+ AND b.status NOT IN ('cancelled', 'no_show')
5613
+ AND b.starts_at < v_ends
5614
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
5615
+ )
5616
+ ORDER BY w.assignee_id
5617
+ LIMIT 1;
5618
+ IF v_professional IS NULL THEN
5619
+ RETURN jsonb_build_object('ok', false, 'error',
5620
+ 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
5621
+ END IF;
5622
+
5623
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
5624
+ IF EXISTS (
4914
5625
  SELECT 1 FROM appointments b
4915
5626
  WHERE b.tenant_id = p_tenant_id
4916
- AND b.assignee_id = c.assignee_id
5627
+ AND b.assignee_id = v_professional
4917
5628
  AND b.status NOT IN ('cancelled', 'no_show')
4918
- AND b.starts_at < c.s_end
4919
- AND COALESCE(b.ends_at, b.starts_at) > c.s_start
4920
- )
4921
- ORDER BY 1;
5629
+ AND b.starts_at < v_ends
5630
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
5631
+ ) THEN
5632
+ RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
5633
+ END IF;
5634
+
5635
+ -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5636
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
5637
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
5638
+ v_service.price, v_service.price, v_notes,
5639
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
5640
+ RETURNING id INTO v_order;
5641
+
5642
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
5643
+ starts_at, ends_at, status, notes, metadata)
5644
+ VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
5645
+ v_starts, v_ends, 'scheduled', v_notes,
5646
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
5647
+ RETURNING id INTO v_booking;
5648
+
5649
+ INSERT INTO items (order_id, service_id, name, quantity, unit_price, total,
5650
+ sort_order, duration_minutes, assignee_id)
5651
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
5652
+ 0, v_service.duration_minutes, v_professional);
5653
+
5654
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
5655
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
5656
+ jsonb_build_object('payload', p_payload, 'order_id', v_order));
5657
+
5658
+ RETURN jsonb_build_object(
5659
+ 'ok', true,
5660
+ 'id', v_booking,
5661
+ 'record', jsonb_build_object(
5662
+ 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
5663
+ 'archetype', 'schedule:appointment'),
5664
+ 'starts_at', v_starts,
5665
+ 'ends_at', v_ends,
5666
+ 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
5667
+ 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
5668
+ 'client_name', v_client.name,
5669
+ 'service_name', v_service.name,
5670
+ 'price', v_service.price,
5671
+ 'status', 'scheduled'
5672
+ )
5673
+ );
4922
5674
  END;
4923
- $$;
5675
+ $_$;
4924
5676
 
4925
- REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
4926
- GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
4927
- TO anon, authenticated, service_role;
5677
+ CREATE FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb
5678
+ LANGUAGE plpgsql SECURITY DEFINER
5679
+ SET search_path TO 'public'
5680
+ AS $_$
5681
+ DECLARE
5682
+ v_denial jsonb;
5683
+ v_id uuid;
5684
+ v_tz text;
5685
+ v_row record;
5686
+ v_starts_raw text;
5687
+ v_starts timestamptz;
5688
+ v_ends timestamptz;
5689
+ v_duration int;
5690
+ v_professional uuid;
5691
+ v_status text;
5692
+ v_notes text;
5693
+ v_wants_move boolean;
5694
+ v_wants_status boolean;
5695
+ BEGIN
5696
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
5697
+ FROM tenants t WHERE t.id = p_tenant_id;
5698
+ IF v_tz IS NULL THEN
5699
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
5700
+ END IF;
4928
5701
 
4929
- -- \xA75 \u2014 public booking write RPC (validated, race-safe, hardcoded statuses)
4930
- CREATE OR REPLACE FUNCTION public.create_public_booking(
4931
- p_tenant_id uuid,
4932
- p_service_id uuid,
4933
- p_starts_at timestamptz,
4934
- p_name text,
4935
- p_phone text,
4936
- p_email text DEFAULT NULL,
4937
- p_notes text DEFAULT NULL
4938
- ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
4939
- LANGUAGE plpgsql SECURITY DEFINER
4940
- SET search_path = public
4941
- AS $$
5702
+ -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5703
+ BEGIN
5704
+ v_id := (p_payload->>'appointment_id')::uuid;
5705
+ v_professional := (p_payload->>'professional_id')::uuid;
5706
+ v_starts_raw := p_payload->>'starts_at';
5707
+ EXCEPTION WHEN OTHERS THEN
5708
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
5709
+ END;
5710
+ v_status := nullif(trim(lower(p_payload->>'status')), '');
5711
+ v_notes := left(p_payload->>'notes', 500);
5712
+ v_wants_move := v_starts_raw IS NOT NULL OR v_professional IS NOT NULL;
5713
+ v_wants_status := v_status IS NOT NULL;
5714
+
5715
+ IF v_id IS NULL THEN
5716
+ RETURN jsonb_build_object('ok', false, 'error',
5717
+ 'appointment_id is required \u2014 search the agenda first and reuse the id of the appointment to change');
5718
+ END IF;
5719
+ IF NOT v_wants_move AND NOT v_wants_status AND v_notes IS NULL THEN
5720
+ RETURN jsonb_build_object('ok', false, 'error',
5721
+ 'nothing to change: send starts_at, professional_id, status or notes');
5722
+ END IF;
5723
+ IF v_status IS NOT NULL
5724
+ AND v_status NOT IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show') THEN
5725
+ RETURN jsonb_build_object('ok', false, 'error',
5726
+ 'status must be one of scheduled, confirmed, in_progress, completed, cancelled, no_show');
5727
+ END IF;
5728
+
5729
+ -- \u2500\u2500 the appointment must exist, in THIS tenant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5730
+ SELECT b.id, b.starts_at, b.ends_at, b.assignee_id, b.status, b.party_id, b.order_id
5731
+ INTO v_row
5732
+ FROM appointments b
5733
+ WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
5734
+ IF NOT FOUND THEN
5735
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown appointment for this tenant');
5736
+ END IF;
5737
+
5738
+ -- \u2500\u2500 authorization: role \u2192 plan. No cap: editing frees nothing and buys
5739
+ -- nothing, so charging bookings_month for a reschedule would be wrong.
5740
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'appointments', 'edit');
5741
+ IF v_denial IS NOT NULL THEN
5742
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
5743
+ END IF;
5744
+
5745
+ -- Duration is preserved across a move \u2014 a 60-minute appointment stays 60.
5746
+ v_duration := GREATEST(
5747
+ COALESCE(EXTRACT(EPOCH FROM (v_row.ends_at - v_row.starts_at)) / 60, 30)::int, 5);
5748
+ v_professional := COALESCE(v_professional, v_row.assignee_id);
5749
+
5750
+ IF v_wants_move THEN
5751
+ IF v_starts_raw IS NOT NULL THEN
5752
+ BEGIN
5753
+ IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
5754
+ v_starts := v_starts_raw::timestamptz; -- explicit offset
5755
+ ELSE
5756
+ v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
5757
+ END IF;
5758
+ EXCEPTION WHEN OTHERS THEN
5759
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid starts_at: ' || SQLERRM);
5760
+ END;
5761
+ ELSE
5762
+ v_starts := v_row.starts_at;
5763
+ END IF;
5764
+ v_ends := v_starts + make_interval(mins => v_duration);
5765
+
5766
+ IF v_starts > now() + interval '365 days' THEN
5767
+ RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be within the next year');
5768
+ END IF;
5769
+
5770
+ -- Working window (exception-aware, 007) for the target professional.
5771
+ IF NOT EXISTS (
5772
+ SELECT 1
5773
+ FROM agenda_working_windows(
5774
+ p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
5775
+ WHERE w.assignee_id = v_professional
5776
+ AND (v_starts AT TIME ZONE v_tz)::time >= w.win_start
5777
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
5778
+ ) THEN
5779
+ RETURN jsonb_build_object('ok', false, 'error',
5780
+ 'that slot is outside the professional''s working hours. Suggest a different time. Times are tenant-local, tz ' || v_tz);
5781
+ END IF;
5782
+
5783
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
5784
+ IF EXISTS (
5785
+ SELECT 1 FROM appointments b
5786
+ WHERE b.tenant_id = p_tenant_id
5787
+ AND b.assignee_id = v_professional
5788
+ AND b.id <> v_id -- never conflicts with itself
5789
+ AND b.status NOT IN ('cancelled', 'no_show')
5790
+ AND b.starts_at < v_ends
5791
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
5792
+ ) THEN
5793
+ RETURN jsonb_build_object('ok', false, 'error',
5794
+ 'that slot is already taken. Suggest a different time.');
5795
+ END IF;
5796
+ END IF;
5797
+
5798
+ -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5799
+ UPDATE appointments b SET
5800
+ starts_at = COALESCE(v_starts, b.starts_at),
5801
+ ends_at = COALESCE(v_ends, b.ends_at),
5802
+ assignee_id = v_professional,
5803
+ status = COALESCE(v_status, b.status),
5804
+ notes = COALESCE(v_notes, b.notes),
5805
+ metadata = COALESCE(b.metadata, '{}'::jsonb) || jsonb_build_object('lastAgentEdit', now()),
5806
+ updated_at = now()
5807
+ WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
5808
+
5809
+ -- The order mirrors the appointment's assignee/status so the financial side
5810
+ -- does not drift from the calendar.
5811
+ IF v_row.order_id IS NOT NULL THEN
5812
+ UPDATE orders o SET
5813
+ assignee_id = v_professional,
5814
+ status = CASE WHEN v_status = 'cancelled' THEN 'cancelled' ELSE o.status END,
5815
+ updated_at = now()
5816
+ WHERE o.id = v_row.order_id AND o.tenant_id = p_tenant_id;
5817
+ END IF;
5818
+
5819
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
5820
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.updateAppointment', 'appointment', v_id::text,
5821
+ jsonb_build_object('payload', p_payload,
5822
+ 'previous', jsonb_build_object(
5823
+ 'starts_at', v_row.starts_at,
5824
+ 'ends_at', v_row.ends_at,
5825
+ 'assignee_id', v_row.assignee_id,
5826
+ 'status', v_row.status)));
5827
+
5828
+ SELECT a.id, a.starts_at, a.ends_at, a.status, a.client_name, a.professional_name
5829
+ INTO v_row
5830
+ FROM v_appointments a WHERE a.id = v_id;
5831
+
5832
+ RETURN jsonb_build_object(
5833
+ 'ok', true,
5834
+ 'id', v_id,
5835
+ 'record', jsonb_build_object(
5836
+ 'ref', jsonb_build_object('id', v_id, 'resource', 'appointments',
5837
+ 'archetype', 'schedule:appointment'),
5838
+ 'starts_at', v_row.starts_at,
5839
+ 'ends_at', v_row.ends_at,
5840
+ 'starts_at_local', to_char(v_row.starts_at AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
5841
+ 'ends_at_local', to_char(v_row.ends_at AT TIME ZONE v_tz, 'HH24:MI'),
5842
+ 'client_name', v_row.client_name,
5843
+ 'professional_name', v_row.professional_name,
5844
+ 'status', v_row.status
5845
+ )
5846
+ );
5847
+ END;
5848
+ $_$;
5849
+
5850
+ CREATE FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_assignee_id uuid DEFAULT NULL::uuid) RETURNS TABLE(booking_id uuid, starts_at timestamp with time zone, ends_at timestamp with time zone)
5851
+ LANGUAGE plpgsql SECURITY DEFINER
5852
+ SET search_path TO 'public'
5853
+ AS $_$
4942
5854
  DECLARE
4943
5855
  v_service record;
4944
5856
  v_person_id uuid;
4945
5857
  v_assignee uuid;
5858
+ v_requested uuid;
4946
5859
  v_ends timestamptz;
4947
5860
  v_order uuid;
4948
5861
  v_booking uuid;
4949
5862
  v_phone text;
4950
5863
  v_tz text;
4951
5864
  v_notes text;
5865
+ v_client_kind text;
4952
5866
  BEGIN
4953
5867
  -- validation: anon-callable, everything tight
4954
5868
  IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
@@ -4972,26 +5886,44 @@ BEGIN
4972
5886
  WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
4973
5887
  AND s.is_active AND s.status = 'active';
4974
5888
  IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
5889
+ -- \u2026and it must be a PUBLIC service, not merely an existing one. A service
5890
+ -- released to particular units is not a tenant-wide offering, and this path has
5891
+ -- no unit to honour the release in; without this an id from a cached page still
5892
+ -- booked what the catalog no longer lists (#201).
5893
+ PERFORM public.agenda_assert_service_is_public(p_tenant_id, p_service_id);
4975
5894
 
4976
5895
  v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
4977
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
5896
+
5897
+ -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
5898
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
5899
+ COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
5900
+ INTO v_tz, v_client_kind
4978
5901
  FROM tenants t WHERE t.id = p_tenant_id;
4979
5902
  IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
5903
+ IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
5904
+
5905
+ -- resolve the professional, in order of trust:
5906
+ -- 1. the id the caller sent (the one the visitor actually saw)
5907
+ -- 2. the tenant's configured default (settings->>'defaultAssigneeId')
5908
+ -- 3. whoever's working hours cover the slot
5909
+ --
5910
+ -- Step 2 is why this migration exists. Older published clients call this with
5911
+ -- 7 named args, leaving p_assignee_id NULL, and step 3 then resolved to
5912
+ -- "lowest assignee UUID" \u2014 an arbitrary instructor who had nothing to do with
5913
+ -- the name the site displayed on the confirmation screen. The default gives
5914
+ -- those clients a correct answer without waiting for a redeploy.
5915
+ v_requested := COALESCE(
5916
+ p_assignee_id,
5917
+ (SELECT NULLIF(t.settings ->> 'defaultAssigneeId', '')::uuid
5918
+ FROM tenants t WHERE t.id = p_tenant_id)
5919
+ );
4980
5920
 
4981
- -- pick the assignee whose working hours cover the slot
4982
- SELECT sc.assignee_id INTO v_assignee
4983
- FROM schedules sc
4984
- WHERE sc.tenant_id = p_tenant_id
4985
- AND sc.kind = 'working_hours'
4986
- AND sc.is_active
4987
- AND (
4988
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
4989
- OR (sc.specific_date IS NULL
4990
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
4991
- )
4992
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
4993
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
4994
- ORDER BY sc.assignee_id
5921
+ SELECT w.assignee_id INTO v_assignee
5922
+ FROM agenda_working_windows(
5923
+ p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, v_requested) w
5924
+ WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start
5925
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
5926
+ ORDER BY w.assignee_id
4995
5927
  LIMIT 1;
4996
5928
  IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
4997
5929
 
@@ -5021,19 +5953,16 @@ BEGIN
5021
5953
  RAISE EXCEPTION 'too many open bookings for this phone';
5022
5954
  END IF;
5023
5955
 
5024
- -- Upsert the customer by (tenant, phone). NOT ON CONFLICT: the partial unique
5025
- -- index this used to infer is gone (spine/110 \u2014 a placeholder phone is shared
5026
- -- by real, distinct people). 003 already settled the replacement: take a
5027
- -- per-(tenant, phone) advisory lock so two first-bookings serialise, then look
5028
- -- before inserting. Oldest match wins, so a returning caller keeps the record
5029
- -- that carries their history.
5956
+ -- upsert the client by (tenant, kind, phone), serialized by advisory lock
5957
+ -- (kind is tenant-configured, so the customer-only partial index can't
5958
+ -- arbitrate)
5030
5959
  PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
5031
5960
 
5032
5961
  SELECT p.id INTO v_person_id
5033
- FROM people p
5034
- WHERE p.tenant_id = p_tenant_id AND p.kind = 'customer' AND p.phone = v_phone
5035
- ORDER BY p.created_at
5036
- LIMIT 1;
5962
+ FROM people p
5963
+ WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
5964
+ ORDER BY p.created_at
5965
+ LIMIT 1;
5037
5966
 
5038
5967
  IF v_person_id IS NOT NULL THEN
5039
5968
  UPDATE people SET
@@ -5043,7 +5972,7 @@ BEGIN
5043
5972
  WHERE id = v_person_id;
5044
5973
  ELSE
5045
5974
  INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
5046
- VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
5975
+ VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
5047
5976
  jsonb_build_object('source', 'public_booking'))
5048
5977
  RETURNING id INTO v_person_id;
5049
5978
  END IF;
@@ -5064,39 +5993,955 @@ BEGIN
5064
5993
  jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
5065
5994
  RETURNING id INTO v_booking;
5066
5995
 
5067
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
5996
+ INSERT INTO items (order_id, service_id, name, quantity, unit_price, total,
5068
5997
  sort_order, duration_minutes, assignee_id)
5069
5998
  VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
5070
5999
  0, v_service.duration_minutes, v_assignee);
5071
6000
 
5072
6001
  RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
5073
6002
  END;
6003
+ $_$;
6004
+
6005
+ CREATE FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid DEFAULT NULL::uuid, p_slot_interval integer DEFAULT 30) RETURNS TABLE(slot_start timestamp with time zone, slot_end timestamp with time zone)
6006
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
6007
+ SET search_path TO 'public'
6008
+ AS $$
6009
+ DECLARE
6010
+ v_tz text;
6011
+ BEGIN
6012
+ -- tight validation: this function is anon-callable
6013
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
6014
+ RAISE EXCEPTION 'invalid duration';
6015
+ END IF;
6016
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
6017
+ RAISE EXCEPTION 'invalid interval';
6018
+ END IF;
6019
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
6020
+ RETURN;
6021
+ END IF;
6022
+
6023
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
6024
+ FROM tenants t WHERE t.id = p_tenant_id;
6025
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant \u2192 empty, not an error
6026
+
6027
+ RETURN QUERY
6028
+ WITH day_schedules AS (
6029
+ SELECT w.assignee_id, w.win_start, w.win_end
6030
+ FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w
6031
+ ),
6032
+ candidate AS (
6033
+ SELECT
6034
+ ds.assignee_id,
6035
+ gs AS s_start,
6036
+ gs + make_interval(mins => p_duration_minutes) AS s_end
6037
+ FROM day_schedules ds
6038
+ CROSS JOIN LATERAL generate_series(
6039
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
6040
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
6041
+ make_interval(mins => p_slot_interval)
6042
+ ) AS gs
6043
+ )
6044
+ SELECT DISTINCT c.s_start, c.s_end
6045
+ FROM candidate c
6046
+ WHERE NOT EXISTS (
6047
+ SELECT 1 FROM appointments b
6048
+ WHERE b.tenant_id = p_tenant_id
6049
+ -- SMART SYNC (gcal 002): this professional's own bookings OR any
6050
+ -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.
6051
+ AND (b.assignee_id = c.assignee_id
6052
+ OR (b.assignee_id IS NULL AND b.kind = 'block'))
6053
+ AND b.status NOT IN ('cancelled', 'no_show')
6054
+ AND b.starts_at < c.s_end
6055
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
6056
+ )
6057
+ ORDER BY 1;
6058
+ END;
5074
6059
  $$;
5075
6060
 
5076
- REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
5077
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
5078
- TO anon, authenticated, service_role;
6061
+ CREATE FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid DEFAULT NULL::uuid) RETURNS jsonb
6062
+ LANGUAGE plpgsql SECURITY DEFINER
6063
+ SET search_path TO 'public'
6064
+ AS $$
6065
+ DECLARE
6066
+ v_action text := nullif(trim(lower(p_action)), '');
6067
+ v_target text;
6068
+ v_row record;
6069
+ v_tz text;
6070
+ v_starts timestamptz;
6071
+ v_event text;
6072
+ v_payload jsonb;
6073
+ BEGIN
6074
+ IF v_action IS NULL OR v_action NOT IN ('confirm', 'cancel') THEN
6075
+ RETURN jsonb_build_object('result', 'not_allowed', 'reason', 'unknown_action');
6076
+ END IF;
6077
+ IF p_booking_id IS NULL THEN
6078
+ RETURN jsonb_build_object('result', 'not_found', 'reason', 'no_booking_id');
6079
+ END IF;
6080
+
6081
+ v_target := CASE WHEN v_action = 'confirm' THEN 'confirmed' ELSE 'cancelled' END;
6082
+
6083
+ -- FOR UPDATE, because two deliveries of the same tap race each other and the
6084
+ -- loser must read the state the winner wrote, not the state both started from.
6085
+ SELECT b.id, b.tenant_id, b.status, b.starts_at
6086
+ INTO v_row
6087
+ FROM public.appointments b
6088
+ WHERE b.id = p_booking_id
6089
+ FOR UPDATE;
6090
+ IF NOT FOUND THEN
6091
+ RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);
6092
+ END IF;
6093
+
6094
+ IF p_expect_tenant_id IS NOT NULL AND v_row.tenant_id <> p_expect_tenant_id THEN
6095
+ RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);
6096
+ END IF;
6097
+
6098
+ -- Already there. The answer is the outcome, not an error: the customer sees
6099
+ -- "confirmado" either way, and a webhook that 500s on a duplicate is a
6100
+ -- webhook Tyxter retries eight times over a day and a half.
6101
+ IF v_row.status = v_target THEN
6102
+ RETURN jsonb_build_object(
6103
+ 'result', v_target, 'changed', false,
6104
+ 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);
6105
+ END IF;
6106
+
6107
+ -- Confirming is only meaningful from the two states that mean "not settled
6108
+ -- yet". 'pending' is the table's own DEFAULT and 'scheduled' is what every
6109
+ -- write path sets, so both are live appointments awaiting the customer.
6110
+ -- Confirming a completed or cancelled one would rewrite history.
6111
+ IF v_action = 'confirm' AND v_row.status NOT IN ('pending', 'scheduled') THEN
6112
+ RETURN jsonb_build_object(
6113
+ 'result', 'not_allowed', 'changed', false,
6114
+ 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);
6115
+ END IF;
6116
+
6117
+ -- Cancelling is allowed from anything that has not already ENDED. A confirmed
6118
+ -- appointment can still be cancelled \u2014 that is the ordinary case \u2014 but one
6119
+ -- already cancelled or already completed cannot.
6120
+ IF v_action = 'cancel' AND v_row.status IN ('cancelled', 'completed') THEN
6121
+ RETURN jsonb_build_object(
6122
+ 'result', 'not_allowed', 'changed', false,
6123
+ 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);
6124
+ END IF;
6125
+
6126
+ UPDATE public.appointments
6127
+ SET status = v_target, updated_at = now()
6128
+ WHERE id = v_row.id;
6129
+
6130
+ -- \u2500\u2500 the fact \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6131
+ -- Emitted HERE and not by the caller. The write happened in the database, so
6132
+ -- the browser emitter never sees it (packages/db/migrations/027 is the whole
6133
+ -- argument), and a webhook that updated the row and then failed to post the
6134
+ -- event would leave a confirmed appointment nobody was told about.
6135
+ --
6136
+ -- The payload is BOOKING_FIELDS from plugin-agenda's message-events.ts, field
6137
+ -- for field, so an automation reacting to a WhatsApp confirmation renders the
6138
+ -- same message as one reacting to a confirmation made in the app.
6139
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
6140
+ FROM public.tenants t WHERE t.id = v_row.tenant_id;
6141
+ v_tz := COALESCE(v_tz, 'America/Sao_Paulo');
6142
+ v_starts := v_row.starts_at;
6143
+
6144
+ SELECT jsonb_build_object(
6145
+ 'customer_name', COALESCE(a.client_name, ''),
6146
+ 'service_name', COALESCE((
6147
+ SELECT string_agg(s->>'name', ' + ')
6148
+ FROM jsonb_array_elements(COALESCE(a.services::jsonb, '[]'::jsonb)) AS s), ''),
6149
+ 'professional_name', COALESCE(a.professional_name, ''),
6150
+ 'booking_date', to_char(v_starts AT TIME ZONE v_tz, 'DD/MM/YYYY'),
6151
+ 'booking_time', to_char(v_starts AT TIME ZONE v_tz, 'HH24:MI'),
6152
+ 'location_name', COALESCE(a.location_name, ''),
6153
+ 'customer_phone', COALESCE(a.client_phone, ''),
6154
+ 'source', 'channel_reply')
6155
+ INTO v_payload
6156
+ FROM public.v_appointments a
6157
+ WHERE a.id = v_row.id;
6158
+
6159
+ v_event := CASE WHEN v_action = 'confirm'
6160
+ THEN 'agenda.booking_confirmed'
6161
+ ELSE 'agenda.booking_cancelled' END;
6162
+
6163
+ PERFORM public.plg_emit_event(
6164
+ v_event,
6165
+ COALESCE(v_payload, '{}'::jsonb),
6166
+ 'booking',
6167
+ v_row.id::text,
6168
+ v_row.tenant_id,
6169
+ NULL);
6170
+
6171
+ RETURN jsonb_build_object(
6172
+ 'result', v_target, 'changed', true, 'event_key', v_event,
6173
+ 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id,
6174
+ 'status', v_target, 'previous_status', v_row.status);
6175
+ END;
6176
+ $$;
6177
+
6178
+
6179
+ -- \u2500\u2500 view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6180
+
6181
+ CREATE VIEW public.v_appointments WITH (security_invoker='true') AS
6182
+ SELECT b.id,
6183
+ b.tenant_id,
6184
+ b.kind,
6185
+ b.starts_at,
6186
+ b.ends_at,
6187
+ b.status,
6188
+ b.notes,
6189
+ b.order_id,
6190
+ b.location_id,
6191
+ b.metadata,
6192
+ b.created_at,
6193
+ b.updated_at,
6194
+ b.party_id AS client_id,
6195
+ COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,
6196
+ pc.phone AS client_phone,
6197
+ pc.email AS client_email,
6198
+ pc.avatar_url AS client_avatar_url,
6199
+ b.assignee_id AS professional_id,
6200
+ ps.name AS professional_name,
6201
+ ps.avatar_url AS professional_avatar_url,
6202
+ l.name AS location_name,
6203
+ o.total AS order_total,
6204
+ o.status AS order_status,
6205
+ ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,
6206
+ o.reference_number,
6207
+ o.status AS stage,
6208
+ (o.metadata ->> 'direction'::text) AS direction,
6209
+ ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg
6210
+ FROM public.items oi
6211
+ WHERE (oi.order_id = b.order_id)) AS services,
6212
+ inv.status AS payment_status,
6213
+ inv.paid AS order_paid,
6214
+ inv.balance AS order_balance
6215
+ FROM (((((public.appointments b
6216
+ LEFT JOIN public.people pc ON ((pc.id = b.party_id)))
6217
+ LEFT JOIN public.people ps ON ((ps.id = b.assignee_id)))
6218
+ LEFT JOIN public.locations l ON ((l.id = b.location_id)))
6219
+ LEFT JOIN public.orders o ON ((o.id = b.order_id)))
6220
+ LEFT JOIN public.v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));
6221
+
6222
+ CREATE VIEW public.rep_appointments_by_period WITH (security_invoker='true') AS
6223
+ SELECT tenant_id,
6224
+ id AS booking_id,
6225
+ (starts_at)::date AS date,
6226
+ starts_at,
6227
+ client_id,
6228
+ client_name,
6229
+ professional_id,
6230
+ professional_name,
6231
+ COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,
6232
+ status,
6233
+ order_total AS revenue,
6234
+ created_at,
6235
+ updated_at
6236
+ FROM public.v_appointments b;
6237
+
6238
+ CREATE VIEW public.plg_agenda_rep_bookings WITH (security_invoker='true') AS
6239
+ SELECT tenant_id,
6240
+ booking_id,
6241
+ date,
6242
+ starts_at,
6243
+ client_id,
6244
+ client_name,
6245
+ professional_id,
6246
+ professional_name,
6247
+ service_name,
6248
+ status,
6249
+ revenue AS booked_revenue,
6250
+ CASE
6251
+ WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN (0)::numeric
6252
+ ELSE COALESCE(revenue, (0)::numeric)
6253
+ END AS effective_revenue,
6254
+ CASE
6255
+ WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN COALESCE(revenue, (0)::numeric)
6256
+ ELSE (0)::numeric
6257
+ END AS lost_revenue,
6258
+ CASE
6259
+ WHEN (status = 'completed'::text) THEN 1
6260
+ ELSE 0
6261
+ END AS is_completed,
6262
+ CASE
6263
+ WHEN (status = 'cancelled'::text) THEN 1
6264
+ ELSE 0
6265
+ END AS is_cancelled,
6266
+ CASE
6267
+ WHEN (status = 'no_show'::text) THEN 1
6268
+ ELSE 0
6269
+ END AS is_no_show,
6270
+ created_at,
6271
+ updated_at
6272
+ FROM public.rep_appointments_by_period b;
6273
+
6274
+ CREATE VIEW public.rep_confirmation_queue WITH (security_invoker='true') AS
6275
+ SELECT tenant_id,
6276
+ id AS booking_id,
6277
+ (starts_at)::date AS date,
6278
+ starts_at,
6279
+ client_id,
6280
+ client_name,
6281
+ professional_id,
6282
+ professional_name,
6283
+ COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,
6284
+ 'pending'::text AS confirmation_status,
6285
+ NULL::text AS confirmation_channel,
6286
+ NULL::timestamp with time zone AS confirmation_sent_at,
6287
+ NULL::timestamp with time zone AS confirmed_at,
6288
+ created_at,
6289
+ updated_at
6290
+ FROM public.v_appointments b
6291
+ WHERE ((status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (starts_at >= (now() - '1 day'::interval)));
6292
+
6293
+ CREATE VIEW public.rep_revenue_by_professional WITH (security_invoker='true') AS
6294
+ SELECT tenant_id,
6295
+ (starts_at)::date AS date,
6296
+ professional_id,
6297
+ COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,
6298
+ (count(*))::integer AS appointment_count,
6299
+ (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,
6300
+ (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,
6301
+ (0)::numeric(14,2) AS commission,
6302
+ max(updated_at) AS updated_at
6303
+ FROM public.v_appointments b
6304
+ WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
6305
+ GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);
6306
+
6307
+ CREATE VIEW public.rep_revenue_by_service WITH (security_invoker='true') AS
6308
+ SELECT b.tenant_id,
6309
+ (b.starts_at)::date AS date,
6310
+ NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,
6311
+ COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,
6312
+ (count(DISTINCT b.id))::integer AS quantity,
6313
+ (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,
6314
+ (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,
6315
+ max(b.updated_at) AS updated_at
6316
+ FROM (public.v_appointments b
6317
+ CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))
6318
+ WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
6319
+ GROUP BY b.tenant_id, ((b.starts_at)::date), NULLIF((service_item.value ->> 'serviceId'::text), ''::text), COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text);
6320
+
6321
+ CREATE VIEW public.v_appointments_sem_order WITH (security_invoker='true') AS
6322
+ SELECT id AS appointment_id,
6323
+ tenant_id,
6324
+ unit_id,
6325
+ kind,
6326
+ status,
6327
+ party_id,
6328
+ assignee_id,
6329
+ starts_at
6330
+ FROM public.appointments a
6331
+ WHERE ((order_id IS NULL) AND (status = ANY (ARRAY['completed'::text, 'invoiced'::text, 'paid'::text])));
6332
+
6333
+ CREATE VIEW public.v_public_services AS
6334
+ SELECT id,
6335
+ tenant_id,
6336
+ name,
6337
+ description,
6338
+ price,
6339
+ currency,
6340
+ COALESCE(duration_minutes, 30) AS duration_minutes,
6341
+ image_url,
6342
+ COALESCE(((metadata ->> 'sort_order'::text))::integer, 0) AS sort_order
6343
+ FROM public.products s
6344
+ WHERE ((kind = 'service'::text) AND is_active AND (status = 'active'::text) AND COALESCE(((metadata ->> 'bookableOnline'::text))::boolean, true) AND (NOT (EXISTS ( SELECT 1
6345
+ FROM app.resource_grants g
6346
+ WHERE ((g.tenant_id = s.tenant_id) AND (g.resource_type = 'catalog.product'::text) AND (g.resource_id = s.id) AND (g.relation = 'available_in'::text))))));
6347
+
6348
+
6349
+ -- \u2500\u2500 acl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5079
6350
 
5080
- SELECT app.reattach_view_dependents();
6351
+ REVOKE ALL ON FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) FROM PUBLIC;
6352
+ GRANT ALL ON FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) TO service_role;
6353
+
6354
+ REVOKE ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) FROM PUBLIC;
6355
+ GRANT ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) TO authenticated;
6356
+ GRANT ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) TO service_role;
6357
+
6358
+ REVOKE ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) FROM PUBLIC;
6359
+ GRANT ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO authenticated;
6360
+ GRANT ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO service_role;
6361
+
6362
+ REVOKE ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) FROM PUBLIC;
6363
+ GRANT ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) TO authenticated;
6364
+ GRANT ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) TO service_role;
6365
+
6366
+ REVOKE ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) FROM PUBLIC;
6367
+ GRANT ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) TO authenticated;
6368
+ GRANT ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) TO service_role;
6369
+
6370
+ REVOKE ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) FROM PUBLIC;
6371
+ GRANT ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) TO authenticated;
6372
+ GRANT ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) TO service_role;
6373
+
6374
+ REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;
6375
+ GRANT ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;
6376
+ GRANT ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;
6377
+
6378
+ REVOKE ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;
6379
+ GRANT ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;
6380
+ GRANT ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;
6381
+
6382
+ REVOKE ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) FROM PUBLIC;
6383
+ GRANT ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) TO authenticated;
6384
+ GRANT ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) TO service_role;
6385
+ GRANT ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) TO anon;
6386
+
6387
+ REVOKE ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) FROM PUBLIC;
6388
+ GRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO authenticated;
6389
+ GRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO service_role;
6390
+ GRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO anon;
6391
+
6392
+ REVOKE ALL ON FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid) FROM PUBLIC;
6393
+ GRANT ALL ON FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid) TO service_role;
6394
+
6395
+ GRANT MAINTAIN ON TABLE public.v_appointments TO anon;
6396
+ GRANT ALL ON TABLE public.v_appointments TO authenticated;
6397
+ GRANT ALL ON TABLE public.v_appointments TO service_role;
6398
+
6399
+ GRANT MAINTAIN ON TABLE public.rep_appointments_by_period TO anon;
6400
+ GRANT ALL ON TABLE public.rep_appointments_by_period TO authenticated;
6401
+ GRANT ALL ON TABLE public.rep_appointments_by_period TO service_role;
6402
+
6403
+ GRANT MAINTAIN ON TABLE public.plg_agenda_rep_bookings TO anon;
6404
+ GRANT ALL ON TABLE public.plg_agenda_rep_bookings TO authenticated;
6405
+ GRANT ALL ON TABLE public.plg_agenda_rep_bookings TO service_role;
6406
+
6407
+ GRANT MAINTAIN ON TABLE public.rep_confirmation_queue TO anon;
6408
+ GRANT ALL ON TABLE public.rep_confirmation_queue TO authenticated;
6409
+ GRANT ALL ON TABLE public.rep_confirmation_queue TO service_role;
6410
+
6411
+ GRANT MAINTAIN ON TABLE public.rep_revenue_by_professional TO anon;
6412
+ GRANT ALL ON TABLE public.rep_revenue_by_professional TO authenticated;
6413
+ GRANT ALL ON TABLE public.rep_revenue_by_professional TO service_role;
6414
+
6415
+ GRANT MAINTAIN ON TABLE public.rep_revenue_by_service TO anon;
6416
+ GRANT ALL ON TABLE public.rep_revenue_by_service TO authenticated;
6417
+ GRANT ALL ON TABLE public.rep_revenue_by_service TO service_role;
6418
+
6419
+ GRANT MAINTAIN ON TABLE public.v_appointments_sem_order TO anon;
6420
+ GRANT ALL ON TABLE public.v_appointments_sem_order TO authenticated;
6421
+ GRANT ALL ON TABLE public.v_appointments_sem_order TO service_role;
6422
+
6423
+ GRANT SELECT,MAINTAIN ON TABLE public.v_public_services TO anon;
6424
+ GRANT ALL ON TABLE public.v_public_services TO authenticated;
6425
+ GRANT ALL ON TABLE public.v_public_services TO service_role;
6426
+
6427
+
6428
+ -- \u2500\u2500 comment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6429
+
6430
+ COMMENT ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) IS 'A booking, its Venda and its lines in one transaction (PRD 06). Replaces the three browser calls createBooking used to make \u2014 closing the tab between them is what left an order nobody could find.';
6431
+
6432
+ COMMENT ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) IS 'Bill a booking in one call (PRD 06). The bridge used to read the order in the browser and then call the invoice RPC; here the lookup and the promotion are one transaction.';
6433
+
6434
+ COMMENT ON VIEW public.v_appointments_sem_order IS 'Appointments that reached a billable state with no Venda behind them (PRD 06). An unbilled appointment is legitimate; a COMPLETED one with nothing commercial attached is the 72% hole the V1 has, arriving early enough to stop.';
6435
+
6436
+ COMMENT ON VIEW public.v_public_services IS 'Anonymous booking catalog. Owner-rights on purpose (anon has no tenant claim), so the release rule is written into the body: only services with no available_in tuple are public \u2014 see #201.';
6437
+
6438
+
6439
+ -- \u2500\u2500 default privileges: restore \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6440
+ -- Put back, so the NEXT thing installed inherits what the chain had.
6441
+
6442
+ DO $dp$ BEGIN
6443
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
6444
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6445
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
6446
+ END $dp$;
6447
+
6448
+ DO $dp$ BEGIN
6449
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
6450
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6451
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
6452
+ END $dp$;
6453
+
6454
+ DO $dp$ BEGIN
6455
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
6456
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6457
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
6458
+ END $dp$;
6459
+
6460
+ DO $dp$ BEGIN
6461
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
6462
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6463
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
6464
+ END $dp$;
6465
+
6466
+ DO $dp$ BEGIN
6467
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
6468
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6469
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
6470
+ END $dp$;
6471
+
6472
+ DO $dp$ BEGIN
6473
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
6474
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6475
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
6476
+ END $dp$;
6477
+
6478
+ DO $dp$ BEGIN
6479
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
6480
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6481
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
6482
+ END $dp$;
6483
+
6484
+ DO $dp$ BEGIN
6485
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
6486
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6487
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
6488
+ END $dp$;
6489
+
6490
+ DO $dp$ BEGIN
6491
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
6492
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6493
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
6494
+ END $dp$;
6495
+
6496
+ DO $dp$ BEGIN
6497
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
6498
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6499
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
6500
+ END $dp$;
6501
+
6502
+ DO $dp$ BEGIN
6503
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
6504
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6505
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
6506
+ END $dp$;
6507
+
6508
+ DO $dp$ BEGIN
6509
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
6510
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6511
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
6512
+ END $dp$;
6513
+
6514
+ DO $dp$ BEGIN
6515
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
6516
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6517
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
6518
+ END $dp$;
6519
+
6520
+ DO $dp$ BEGIN
6521
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
6522
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6523
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
6524
+ END $dp$;
6525
+
6526
+ DO $dp$ BEGIN
6527
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
6528
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6529
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
6530
+ END $dp$;
6531
+
6532
+ DO $dp$ BEGIN
6533
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
6534
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6535
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
6536
+ END $dp$;
6537
+
6538
+ DO $dp$ BEGIN
6539
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
6540
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6541
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
6542
+ END $dp$;
6543
+
6544
+ DO $dp$ BEGIN
6545
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
6546
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6547
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
6548
+ END $dp$;
6549
+
6550
+ DO $dp$ BEGIN
6551
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
6552
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6553
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
6554
+ END $dp$;
6555
+
6556
+ DO $dp$ BEGIN
6557
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
6558
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6559
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
6560
+ END $dp$;
6561
+
6562
+ DO $dp$ BEGIN
6563
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
6564
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6565
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
6566
+ END $dp$;
6567
+
6568
+ DO $dp$ BEGIN
6569
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
6570
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6571
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
6572
+ END $dp$;
6573
+
6574
+ DO $dp$ BEGIN
6575
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
6576
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6577
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
6578
+ END $dp$;
6579
+
6580
+ DO $dp$ BEGIN
6581
+ EXECUTE $stmt$--
6582
+ -- PostgreSQL database dump complete
6583
+ --$stmt$;
6584
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
6585
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
6586
+ -- PostgreSQL database dump complete
6587
+ --$stmt$;
6588
+ END $dp$;
6589
+ `;
6590
+ var MIGRATION_001_THE_CONNECTOR_DEFINERS_KEEP_A_PINNED_PATH = `-- ---------------------------------------------------------------------------
6591
+ -- 001_the_connector_definers_keep_a_pinned_path \u2014 the half of 019 the squash
6592
+ -- could not photograph (#283, #338).
6593
+ --
6594
+ -- 019 pinned \`search_path\` on every SECURITY DEFINER function this plugin owns,
6595
+ -- including \`fn_gcal_push_booking\`, which the GOOGLE CALENDAR CONNECTOR creates.
6596
+ -- Connector SQL is its own ledger source: it is not in chain.json, so it is not
6597
+ -- in the schema \`emit-baseline\` photographs, so 000_baseline.sql cannot carry
6598
+ -- the pin the way it carries the other 40.
6599
+ --
6600
+ -- Without this file a fresh install applies the connector's own 001-005, which
6601
+ -- create the function unpinned, and nothing ever pins it again \u2014 a definer
6602
+ -- function resolving names the caller's way, which is the exact shape #283
6603
+ -- exists to close. So 019's loop stays a migration.
6604
+ --
6605
+ -- Idempotent by construction: it pins only what is unpinned, and skips the
6606
+ -- function entirely on an install without the connector.
6607
+ -- ---------------------------------------------------------------------------
6608
+
6609
+ DO $pin$
6610
+ DECLARE
6611
+ v_fn record;
6612
+ v_pinned integer := 0;
6613
+ BEGIN
6614
+ FOR v_fn IN
6615
+ SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args
6616
+ FROM pg_proc p
6617
+ JOIN pg_namespace n ON n.oid = p.pronamespace
6618
+ WHERE n.nspname = 'public'
6619
+ AND p.prosecdef
6620
+ AND p.proname = ANY (ARRAY['create_public_booking', 'fn_gcal_push_booking'])
6621
+ AND NOT EXISTS (
6622
+ SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg
6623
+ WHERE cfg LIKE 'search\\_path=%'
6624
+ )
6625
+ LOOP
6626
+ -- \`public, pg_catalog\`, not '': these bodies read public objects by bare
6627
+ -- name and were written that way. What closes the hole is that the path is
6628
+ -- FIXED rather than inherited \u2014 the booking path is called by an anonymous
6629
+ -- shopper, and that caller's path should count for nothing.
6630
+ EXECUTE format('ALTER FUNCTION public.%I(%s) SET search_path = public, pg_catalog',
6631
+ v_fn.proname, v_fn.args);
6632
+ v_pinned := v_pinned + 1;
6633
+ END LOOP;
6634
+ IF v_pinned > 0 THEN
6635
+ RAISE NOTICE 'plugin-agenda: pinned search_path on % connector definer function(s)', v_pinned;
6636
+ END IF;
6637
+ END $pin$;
6638
+ `;
6639
+ var MIGRATION_002_THE_APPOINTMENT_NAMES_THE_UNIT = `-- ============================================================================
6640
+ -- 002_the_appointment_names_the_unit \u2014 v_appointments leaves the locations
6641
+ -- projection (#214 / #352).
6642
+ --
6643
+ -- The view LEFT JOINed public.locations, a compat projection of app.units. A
6644
+ -- view is a HARD dependency, so this join is what made the projection
6645
+ -- undroppable by the spine \u2014 which runs first and cannot remove a name a
6646
+ -- plugin applied later still selects.
6647
+ --
6648
+ -- The view is already security_invoker and app.units already grants SELECT to
6649
+ -- authenticated under its own RLS, so nothing about who sees which appointment
6650
+ -- changes: the same policy decides, one relation earlier.
6651
+ -- ============================================================================
6652
+
6653
+ CREATE OR REPLACE VIEW public.v_appointments WITH (security_invoker='true') AS
6654
+ SELECT b.id,
6655
+ b.tenant_id,
6656
+ b.kind,
6657
+ b.starts_at,
6658
+ b.ends_at,
6659
+ b.status,
6660
+ b.notes,
6661
+ b.order_id,
6662
+ b.location_id,
6663
+ b.metadata,
6664
+ b.created_at,
6665
+ b.updated_at,
6666
+ b.party_id AS client_id,
6667
+ COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,
6668
+ pc.phone AS client_phone,
6669
+ pc.email AS client_email,
6670
+ pc.avatar_url AS client_avatar_url,
6671
+ b.assignee_id AS professional_id,
6672
+ ps.name AS professional_name,
6673
+ ps.avatar_url AS professional_avatar_url,
6674
+ l.name AS location_name,
6675
+ o.total AS order_total,
6676
+ o.status AS order_status,
6677
+ ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,
6678
+ o.reference_number,
6679
+ o.status AS stage,
6680
+ (o.metadata ->> 'direction'::text) AS direction,
6681
+ ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg
6682
+ FROM public.items oi
6683
+ WHERE (oi.order_id = b.order_id)) AS services,
6684
+ inv.status AS payment_status,
6685
+ inv.paid AS order_paid,
6686
+ inv.balance AS order_balance
6687
+ FROM (((((public.appointments b
6688
+ LEFT JOIN public.people pc ON ((pc.id = b.party_id)))
6689
+ LEFT JOIN public.people ps ON ((ps.id = b.assignee_id)))
6690
+ LEFT JOIN app.units l ON ((l.id = b.location_id)))
6691
+ LEFT JOIN public.orders o ON ((o.id = b.order_id)))
6692
+ LEFT JOIN public.v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));`;
6693
+ var MIGRATION_003_THE_CLIENT_ARRIVES_BEFORE_BEING_SERVED = `-- 003_the_client_arrives_before_being_served.sql
6694
+ --
6695
+ -- Falta um estado entre "confirmado" e "em atendimento": AGUARDANDO.
6696
+ --
6697
+ -- \xC9 o estado que o check-in produz \u2014 a cliente chegou, est\xE1 na recep\xE7\xE3o, ainda
6698
+ -- n\xE3o sentou na cadeira. Hoje o ciclo do \`appointment\` vai de \`confirmed\`
6699
+ -- direto para \`in_progress\`, e quem recebe \xE9 obrigado a escolher entre mentir
6700
+ -- (dizer que j\xE1 est\xE1 em atendimento) ou n\xE3o registrar nada. As duas respostas
6701
+ -- apagam a informa\xE7\xE3o que a recep\xE7\xE3o mais usa: quem j\xE1 est\xE1 aqui.
6702
+ --
6703
+ -- No V1 esse estado existe e \xE9 o padr\xE3o da tela de check-in, ao lado de
6704
+ -- \`scheduled\`, \`confirmed\` e \`in_progress\`. Port\xE1-lo \xE9 o que permite a tela
6705
+ -- fazer sentido \u2014 sem ele, "fazer check-in" n\xE3o teria o que gravar.
6706
+ --
6707
+ -- Entra em \`appointment\` e em \`service_order\`: s\xE3o a mesma coisa com nomes
6708
+ -- diferentes, e uma ordem de servi\xE7o tamb\xE9m tem algu\xE9m esperando.
6709
+
6710
+ UPDATE public.order_kinds
6711
+ SET statuses = (
6712
+ SELECT array_agg(DISTINCT s ORDER BY s)
6713
+ FROM unnest(coalesce(statuses, ARRAY[]::text[]) || ARRAY['waiting']) AS s
6714
+ ),
6715
+ updated_at = now()
6716
+ WHERE kind IN ('appointment', 'service_order')
6717
+ AND NOT ('waiting' = ANY (coalesce(statuses, ARRAY[]::text[])));
6718
+ `;
6719
+ var MIGRATION_004_THE_APPOINTMENT_CARRIES_ITS_UNIT = `-- 004_the_appointment_carries_its_unit.sql
6720
+ --
6721
+ -- Um agendamento criado PELA TELA nasce com \`unit_id\` NULO: o formul\xE1rio coleta
6722
+ -- o local e o provider nunca envia a unidade. E \`location_id\` j\xE1 \xC9 uma unidade \u2014
6723
+ -- aponta para o mesmo \`app.units\`.
6724
+ --
6725
+ -- O efeito n\xE3o \xE9 cosm\xE9tico. O escopo por unidade \xE9 o que faz a recep\xE7\xE3o de uma
6726
+ -- filial ver a fila DELA; uma linha sem unidade n\xE3o \xE9 filtrada por unidade
6727
+ -- nenhuma, ent\xE3o ela aparece para TODAS. Num tenant de franquia \u2014 e a rede
6728
+ -- sint\xE9tica tem seis unidades \u2014 isso \xE9 atendimento de uma filial vis\xEDvel na
6729
+ -- recep\xE7\xE3o da outra.
6730
+ --
6731
+ -- Apareceu numa jornada real: duas clientes no mesmo dia, uma marcada pela tela
6732
+ -- (sem unidade) e outra com unidade; a recep\xE7\xE3o de Norte \u2014 Centro via a primeira
6733
+ -- e n\xE3o a segunda. O filtro estava certo. O dado \xE9 que chegava incompleto.
6734
+ --
6735
+ -- POR QUE GATILHO, E N\xC3O S\xD3 O CAMPO NO PROVIDER. O provider \xE9 um escritor entre
6736
+ -- v\xE1rios: o agente cria agendamento, a importa\xE7\xE3o cria, o totem cria. Corrigir
6737
+ -- s\xF3 a tela deixaria os outros escrevendo linha sem unidade \u2014 e o furo \xE9 de
6738
+ -- VISIBILIDADE, que n\xE3o se conserta pela metade.
6739
+ CREATE OR REPLACE FUNCTION public.trg_agenda_stamp_unit()
6740
+ RETURNS trigger
6741
+ LANGUAGE plpgsql
6742
+ AS $$
6743
+ BEGIN
6744
+ IF NEW.unit_id IS NULL AND NEW.location_id IS NOT NULL THEN
6745
+ NEW.unit_id := NEW.location_id;
6746
+ END IF;
6747
+ RETURN NEW;
6748
+ END $$;
6749
+
6750
+ COMMENT ON FUNCTION public.trg_agenda_stamp_unit() IS
6751
+ 'Carimba a unidade do agendamento a partir do local, que \xE9 a mesma app.units. Sem ela a linha escapa do escopo por unidade e aparece para todas as filiais (004).';
6752
+
6753
+ DROP TRIGGER IF EXISTS appointments_stamp_unit ON public.appointments;
6754
+ CREATE TRIGGER appointments_stamp_unit
6755
+ BEFORE INSERT OR UPDATE OF location_id, unit_id ON public.appointments
6756
+ FOR EACH ROW
6757
+ EXECUTE FUNCTION public.trg_agenda_stamp_unit();
6758
+
6759
+ -- \u2500\u2500 e os que j\xE1 nasceram sem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6760
+ UPDATE public.appointments
6761
+ SET unit_id = location_id
6762
+ WHERE unit_id IS NULL AND location_id IS NOT NULL;
6763
+ `;
6764
+ var MIGRATION_005_THE_QUEUE_KNOWS_HOW_LONG_SHE_WAITED = `-- 005_the_queue_knows_how_long_she_waited.sql
6765
+ --
6766
+ -- A fila mostra QUEM chegou e n\xE3o H\xC1 QUANTO TEMPO. Quem est\xE1 esperando h\xE1
6767
+ -- quarenta minutos e quem acabou de entrar aparecem iguais, e \xE9 justamente essa
6768
+ -- diferen\xE7a que decide quem a recep\xE7\xE3o chama primeiro.
6769
+ --
6770
+ -- O V1 mede por \`checked_in_at\`, e o coment\xE1rio dele explica por que n\xE3o serve
6771
+ -- \`updated_at\`: qualquer edi\xE7\xE3o no agendamento o reescreve, ent\xE3o trocar uma
6772
+ -- observa\xE7\xE3o \xE0s 14h faria a cliente que chegou \xE0s 13h parecer rec\xE9m-chegada. Um
6773
+ -- rel\xF3gio que qualquer coisa reinicia n\xE3o \xE9 rel\xF3gio.
6774
+ --
6775
+ -- O carimbo \xE9 do BANCO, e n\xE3o da tela: quem faz check-in pode ser a recep\xE7\xE3o
6776
+ -- pelo navegador, o totem, ou o pr\xF3prio agente. Tr\xEAs escritores, um s\xF3 instante
6777
+ -- verdadeiro \u2014 e o rel\xF3gio do servidor \xE9 o \xFAnico que os tr\xEAs compartilham.
6778
+ ALTER TABLE public.appointments
6779
+ ADD COLUMN IF NOT EXISTS checked_in_at timestamptz;
6780
+
6781
+ COMMENT ON COLUMN public.appointments.checked_in_at IS
6782
+ 'Quando a pessoa CHEGOU. N\xE3o use updated_at para isso: qualquer edi\xE7\xE3o o reescreve e o tempo de espera mente (005).';
6783
+
6784
+ -- \u2500\u2500 e o carimbo acontece na transi\xE7\xE3o, n\xE3o no cliente \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6785
+ CREATE OR REPLACE FUNCTION public.trg_agenda_stamp_checkin()
6786
+ RETURNS trigger
6787
+ LANGUAGE plpgsql
6788
+ AS $$
6789
+ BEGIN
6790
+ -- S\xF3 na ENTRADA em waiting: reentrar no estado (uma corre\xE7\xE3o de status, um
6791
+ -- salvamento qualquer) n\xE3o pode reiniciar a espera de quem j\xE1 est\xE1 sentada.
6792
+ IF NEW.status = 'waiting' AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM 'waiting')
6793
+ AND NEW.checked_in_at IS NULL THEN
6794
+ NEW.checked_in_at := now();
6795
+ END IF;
6796
+ RETURN NEW;
6797
+ END $$;
6798
+
6799
+ COMMENT ON FUNCTION public.trg_agenda_stamp_checkin() IS
6800
+ 'Carimba a chegada ao ENTRAR em waiting, uma vez. Reentrar no estado n\xE3o reinicia a espera (005).';
6801
+
6802
+ DROP TRIGGER IF EXISTS appointments_stamp_checkin ON public.appointments;
6803
+ CREATE TRIGGER appointments_stamp_checkin
6804
+ BEFORE INSERT OR UPDATE OF status ON public.appointments
6805
+ FOR EACH ROW
6806
+ EXECUTE FUNCTION public.trg_agenda_stamp_checkin();
6807
+
6808
+ -- Quem j\xE1 est\xE1 esperando desde antes deste arquivo herda o \xFAltimo instante
6809
+ -- conhecido \u2014 impreciso, e ainda assim melhor que um travess\xE3o.
6810
+ UPDATE public.appointments
6811
+ SET checked_in_at = updated_at
6812
+ WHERE status = 'waiting' AND checked_in_at IS NULL;
6813
+ `;
6814
+ var MIGRATION_006_THE_AGENDA_STOPS_PRICING_EVERY_INVOICE = `-- ---------------------------------------------------------------------------
6815
+ -- 006_the_agenda_stops_pricing_every_invoice.sql \u2014 ler DOIS agendamentos deixa
6816
+ -- de custar a apura\xE7\xE3o de 8.134 faturas.
6817
+ --
6818
+ -- O SINTOMA: \`GET /rest/v1/v_appointments?...\` devolvendo 500 com
6819
+ -- \`57014: canceling statement due to statement timeout\`, de forma reprodut\xEDvel,
6820
+ -- ao abrir a agenda depois que existe agendamento na semana. Enquanto a semana
6821
+ -- estava vazia a mesma consulta respondia em 12ms \u2014 o que escondeu o problema e
6822
+ -- fez ele parecer intermitente.
6823
+ --
6824
+ -- \u2500\u2500 A medi\xE7\xE3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6825
+ -- EXPLAIN ANALYZE da consulta exata, com 2 agendamentos na janela:
6826
+ --
6827
+ -- Nested Loop Left Join actual time=17137..21252 rows=2
6828
+ -- Join Filter: (o_1.id = b.order_id)
6829
+ -- Rows Removed by Join Filter: 16268 \u2190 dezesseis mil
6830
+ -- -> Nested Loop ... actual time=5619..10590 rows=8134 loops=2
6831
+ -- Execution Time: 21272 ms
6832
+ --
6833
+ -- \`v_invoice_balances\` foi calculada POR INTEIRO \u2014 as 8.134 faturas do tenant \u2014
6834
+ -- e DUAS VEZES, uma por agendamento. S\xF3 depois o \`Join Filter\` jogou fora
6835
+ -- 16.268 das 16.270 linhas para ficar com as 2 que interessavam.
6836
+ --
6837
+ -- \u2500\u2500 Por que o filtro n\xE3o desceu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6838
+ -- A view fazia:
6839
+ --
6840
+ -- LEFT JOIN v_invoice_balances inv ON inv.invoice_id = b.order_id
6841
+ --
6842
+ -- Isso \xE9 um JOIN entre duas rela\xE7\xF5es, n\xE3o um filtro constante. O planejador n\xE3o
6843
+ -- tem por onde empurrar \`b.order_id\` para dentro de um agregado do outro lado:
6844
+ -- ele precisa produzir o lado direito inteiro para ent\xE3o casar as chaves. E o
6845
+ -- lado direito \xE9 um \`GROUP BY order_id\` sobre a \xE1rvore financeira inteira
6846
+ -- (faturas \u2192 parcelas \u2192 aloca\xE7\xF5es \u2192 movimentos), cada n\xEDvel pagando a RLS por
6847
+ -- linha. 8.134 linhas \xD7 ~0,45ms de pol\xEDtica \xD7 tr\xEAs n\xEDveis \xE9 o or\xE7amento todo.
6848
+ --
6849
+ -- \u2500\u2500 A corre\xE7\xE3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6850
+ -- \`LEFT JOIN LATERAL\`. A correla\xE7\xE3o deixa de ser uma condi\xE7\xE3o de join e vira um
6851
+ -- PAR\xC2METRO da subconsulta: \`WHERE vb.invoice_id = b.order_id\` \xE9 avaliado com
6852
+ -- \`b.order_id\` j\xE1 conhecido, e \`invoice_id\` \xE9 exatamente a chave de agrupamento
6853
+ -- de \`v_invoice_balances\` (\`GROUP BY t.order_id\`, depois \`o.id = tb.order_id\`).
6854
+ -- Um qual sobre a chave de agrupamento desce por baixo do agregado, e o que
6855
+ -- era uma varredura de 8.134 faturas vira uma sondagem de \xEDndice de UMA.
6856
+ --
6857
+ -- Nada muda no que a view devolve: mesmas colunas, mesma ordem, mesmos tipos \u2014
6858
+ -- \`LEFT JOIN LATERAL \u2026 ON true\` tem exatamente a sem\xE2ntica do LEFT JOIN que
6859
+ -- substitui, inclusive o NULL quando n\xE3o h\xE1 fatura.
6860
+ --
6861
+ -- \u2500\u2500 O que esta migration N\xC3O conserta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6862
+ -- O multiplicador continua sendo a RLS por linha (~0,45ms por linha por
6863
+ -- tabela). Aqui ele deixa de doer porque o n\xFAmero de linhas cai de 8.134 para
6864
+ -- 1, n\xE3o porque ficou barato. Qualquer outra leitura que varra as tabelas
6865
+ -- financeiras inteiras vai reencontr\xE1-lo \u2014 ver 016 e 019 para a forma de tirar
6866
+ -- pergunta constante de dentro do la\xE7o.
6867
+ --
6868
+ -- MORA NA CORRENTE DA AGENDA, e n\xE3o na do n\xFAcleo, porque redefine
6869
+ -- \`v_appointments\` \u2014 view da agenda \u2014 lendo \`v_invoice_balances\`, view do
6870
+ -- financeiro. A corrente \xE9 n\xFAcleo \u2192 financeiro \u2192 \u2026 \u2192 agenda, ent\xE3o este \xE9 o
6871
+ -- \xFAnico degrau em que as duas j\xE1 existem. Nascida como 021 no n\xFAcleo, ela
6872
+ -- falhava no \`apply\` com "relation v_invoice_balances does not exist", e o
6873
+ -- db-from-zero foi quem disse isso em voz alta.
6874
+ -- ---------------------------------------------------------------------------
6875
+
6876
+ -- \`WITH (security_invoker = true)\` expl\xEDcito: \`CREATE OR REPLACE VIEW\` N\xC3O
6877
+ -- herda as op\xE7\xF5es da view que substitui, e sem isto ela volta a rodar com os
6878
+ -- direitos do dono \u2014 as pol\xEDticas de quem l\xEA deixam de valer. \xC9 o que a
6879
+ -- asser\xE7\xE3o N16 da postura grada, e foi ela que pegou.
6880
+ CREATE OR REPLACE VIEW public.v_appointments WITH (security_invoker = true) AS
6881
+ SELECT b.id,
6882
+ b.tenant_id,
6883
+ b.kind,
6884
+ b.starts_at,
6885
+ b.ends_at,
6886
+ b.status,
6887
+ b.notes,
6888
+ b.order_id,
6889
+ b.location_id,
6890
+ b.metadata,
6891
+ b.created_at,
6892
+ b.updated_at,
6893
+ b.party_id AS client_id,
6894
+ COALESCE(pc.name, b.metadata ->> 'title'::text) AS client_name,
6895
+ pc.phone AS client_phone,
6896
+ pc.email AS client_email,
6897
+ pc.avatar_url AS client_avatar_url,
6898
+ b.assignee_id AS professional_id,
6899
+ ps.name AS professional_name,
6900
+ ps.avatar_url AS professional_avatar_url,
6901
+ l.name AS location_name,
6902
+ o.total AS order_total,
6903
+ o.status AS order_status,
6904
+ EXTRACT(epoch FROM COALESCE(b.ends_at, b.starts_at) - b.starts_at)::integer / 60 AS total_duration_minutes,
6905
+ o.reference_number,
6906
+ o.status AS stage,
6907
+ o.metadata ->> 'direction'::text AS direction,
6908
+ ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg
6909
+ FROM items oi
6910
+ WHERE oi.order_id = b.order_id) AS services,
6911
+ inv.status AS payment_status,
6912
+ inv.paid AS order_paid,
6913
+ inv.balance AS order_balance
6914
+ FROM appointments b
6915
+ LEFT JOIN people pc ON pc.id = b.party_id
6916
+ LEFT JOIN people ps ON ps.id = b.assignee_id
6917
+ LEFT JOIN app.units l ON l.id = b.location_id
6918
+ LEFT JOIN orders o ON o.id = b.order_id
6919
+ -- LATERAL e n\xE3o JOIN: assim \`b.order_id\` \xE9 PAR\xC2METRO da subconsulta e o
6920
+ -- filtro desce por baixo do \`GROUP BY order_id\` de v_invoice_balances.
6921
+ -- Como JOIN, o agregado inteiro (8.134 faturas) era produzido por
6922
+ -- agendamento e descartado depois \u2014 21s para devolver 2 linhas.
6923
+ LEFT JOIN LATERAL (
6924
+ SELECT vb.status, vb.paid, vb.balance
6925
+ FROM v_invoice_balances vb
6926
+ WHERE vb.invoice_id = b.order_id
6927
+ LIMIT 1
6928
+ ) inv ON true;
5081
6929
  `;
6930
+ var MIGRATIONS = [
6931
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
6932
+ { id: "001_the_connector_definers_keep_a_pinned_path", sql: MIGRATION_001_THE_CONNECTOR_DEFINERS_KEEP_A_PINNED_PATH },
6933
+ { id: "002_the_appointment_names_the_unit", sql: MIGRATION_002_THE_APPOINTMENT_NAMES_THE_UNIT },
6934
+ { id: "003_the_client_arrives_before_being_served", sql: MIGRATION_003_THE_CLIENT_ARRIVES_BEFORE_BEING_SERVED },
6935
+ { id: "004_the_appointment_carries_its_unit", sql: MIGRATION_004_THE_APPOINTMENT_CARRIES_ITS_UNIT },
6936
+ { id: "005_the_queue_knows_how_long_she_waited", sql: MIGRATION_005_THE_QUEUE_KNOWS_HOW_LONG_SHE_WAITED },
6937
+ { id: "006_the_agenda_stops_pricing_every_invoice", sql: MIGRATION_006_THE_AGENDA_STOPS_PRICING_EVERY_INVOICE }
6938
+ ];
5082
6939
  function computeCommissionAmount(total, ratePercent) {
5083
6940
  const base = Number(total) || 0;
5084
6941
  const rate = Number(ratePercent) || 0;
5085
6942
  if (base <= 0 || rate <= 0) return 0;
5086
6943
  return Math.round(base * (rate / 100) * 100) / 100;
5087
6944
  }
5088
- function computeStatus(movements) {
5089
- if (movements.length === 0) return "none";
5090
- const allPaid = movements.every((m) => m.status === "paid");
5091
- if (allPaid) return "paid";
5092
- const allCancelled = movements.every((m) => m.status === "cancelled");
5093
- if (allCancelled) return "cancelled";
5094
- const anyOverdue = movements.some((m) => m.status === "overdue");
5095
- if (anyOverdue) return "overdue";
5096
- const anyPaid = movements.some((m) => m.status === "paid" || m.status === "partial");
5097
- if (anyPaid) return "partial";
5098
- return "pending";
5099
- }
5100
6945
  function createFinancialBridge(provider2) {
5101
6946
  return {
5102
6947
  async resolvePaymentStatuses(orderIds) {
@@ -5105,26 +6950,14 @@ function createFinancialBridge(provider2) {
5105
6950
  const supabase = getSupabaseClientOptional();
5106
6951
  if (!supabase) return result;
5107
6952
  try {
5108
- const [{ data: orders }, { data: movements }] = await Promise.all([
5109
- supabase.from("orders").select("id, kind, status, total, reference_number, metadata").in("id", orderIds),
5110
- supabase.from("plg_financial_movements").select("invoice_id, status, paid_amount").in("invoice_id", orderIds)
5111
- ]);
5112
- const movementsByOrderId = /* @__PURE__ */ new Map();
5113
- for (const movement of movements ?? []) {
5114
- const invoiceId = movement.invoice_id;
5115
- if (!invoiceId) continue;
5116
- movementsByOrderId.set(invoiceId, [...movementsByOrderId.get(invoiceId) ?? [], movement]);
5117
- }
5118
- for (const order of orders ?? []) {
5119
- const orderMovements = movementsByOrderId.get(order.id) ?? [];
5120
- const meta = order.metadata ?? {};
5121
- const paidAmount = orderMovements.length > 0 ? orderMovements.reduce((sum, movement) => sum + (Number(movement.paid_amount) || 0), 0) : Number(meta.paidAmount) || 0;
5122
- result.set(order.id, {
5123
- invoiceId: order.id,
5124
- referenceNumber: order.reference_number,
5125
- status: orderMovements.length > 0 ? computeStatus(orderMovements) : mapInvoiceStatus(order.status),
5126
- totalAmount: Number(order.total) || 0,
5127
- paidAmount
6953
+ const { data: rows } = await supabase.from("v_order_payment_status").select("order_id, order_status, reference_number, total_amount, paid_amount, status").in("order_id", orderIds);
6954
+ for (const row of rows ?? []) {
6955
+ result.set(row.order_id, {
6956
+ invoiceId: row.order_id,
6957
+ referenceNumber: row.reference_number,
6958
+ status: row.status === "none" ? mapInvoiceStatus(row.order_status) : row.status,
6959
+ totalAmount: Number(row.total_amount) || 0,
6960
+ paidAmount: Number(row.paid_amount) || 0
5128
6961
  });
5129
6962
  }
5130
6963
  } catch {
@@ -5134,13 +6967,12 @@ function createFinancialBridge(provider2) {
5134
6967
  async getPaymentDetail(orderId) {
5135
6968
  const supabase = getSupabaseClientOptional();
5136
6969
  if (!supabase) return null;
5137
- const [orderRes, movRes] = await Promise.all([
5138
- supabase.from("orders").select("id, status, total, reference_number, metadata").eq("id", orderId).single(),
6970
+ const [headRes, movRes] = await Promise.all([
6971
+ supabase.from("v_order_payment_status").select("order_id, order_status, reference_number, total_amount, paid_amount, status").eq("order_id", orderId).single(),
5139
6972
  supabase.from("plg_financial_movements").select("*").eq("invoice_id", orderId).order("due_date")
5140
6973
  ]);
5141
- const order = orderRes.data;
6974
+ const order = headRes.data;
5142
6975
  if (!order) return null;
5143
- const meta = order.metadata ?? {};
5144
6976
  const typeIds = [...new Set((movRes.data ?? []).map((m) => m.payment_method_type_id).filter(Boolean))];
5145
6977
  let typeNames = {};
5146
6978
  if (typeIds.length > 0) {
@@ -5160,11 +6992,11 @@ function createFinancialBridge(provider2) {
5160
6992
  cardInstallments: m.card_installments ? Number(m.card_installments) : void 0
5161
6993
  }));
5162
6994
  return {
5163
- invoiceId: order.id,
6995
+ invoiceId: order.order_id,
5164
6996
  referenceNumber: order.reference_number,
5165
- status: movements.length > 0 ? computeStatus(movements) : mapInvoiceStatus(order.status),
5166
- totalAmount: Number(order.total) || 0,
5167
- paidAmount: Number(meta.paidAmount) || 0,
6997
+ status: order.status === "none" ? mapInvoiceStatus(order.order_status) : order.status,
6998
+ totalAmount: Number(order.total_amount) || 0,
6999
+ paidAmount: Number(order.paid_amount) || 0,
5168
7000
  movements
5169
7001
  };
5170
7002
  },
@@ -5196,11 +7028,11 @@ function createFinancialBridge(provider2) {
5196
7028
  async createCommissionMovement(orderId, options) {
5197
7029
  const supabase = getSupabaseClientOptional();
5198
7030
  if (!supabase) return null;
5199
- const { data: order } = await supabase.from("orders").select("id, tenant_id, assignee_id, total").eq("id", orderId).single();
5200
- if (!order || !order.assignee_id) return null;
5201
- const { data: staff } = await supabase.from("v_professional_rates").select("commission_rate").eq("id", order.assignee_id).single();
7031
+ const { data: order } = await supabase.from("v_appointments").select("order_id, tenant_id, professional_id, order_total").eq("order_id", orderId).limit(1).maybeSingle();
7032
+ if (!order || !order.professional_id) return null;
7033
+ const { data: staff } = await supabase.from("v_professional_rates").select("commission_rate").eq("id", order.professional_id).single();
5202
7034
  const rate = Number(staff?.commission_rate) || 0;
5203
- const base = Number(order.total) || 0;
7035
+ const base = Number(order.order_total) || 0;
5204
7036
  const amount = computeCommissionAmount(base, rate);
5205
7037
  if (amount <= 0) return null;
5206
7038
  const { data: existing } = await supabase.from("plg_financial_movements").select("id").eq("invoice_id", orderId).eq("movement_kind", "commission").limit(1);
@@ -5219,7 +7051,7 @@ function createFinancialBridge(provider2) {
5219
7051
  metadata: {
5220
7052
  source: "agenda",
5221
7053
  kind: "commission",
5222
- professionalId: order.assignee_id,
7054
+ professionalId: order.professional_id,
5223
7055
  commissionRate: rate,
5224
7056
  baseAmount: base
5225
7057
  }
@@ -5382,7 +7214,7 @@ function createGoogleCalendarProvider() {
5382
7214
  calendarId: data.calendar_id ?? "primary",
5383
7215
  active: isIntegrationActive(data),
5384
7216
  // The sync state row existing is not the same as the clinic having
5385
- // authorized Google — `plg_connections` owns that answer now, and the
7217
+ // authorized Google — `connections` owns that answer now, and the
5386
7218
  // connector asks it through the connection store.
5387
7219
  connected: isIntegrationActive(data),
5388
7220
  lastSyncAt: data.last_sync_at ?? void 0
@@ -6179,7 +8011,7 @@ var googleCalendarConnectorDef = {
6179
8011
  /**
6180
8012
  * The DETAIL screen, where health is worth a round-trip.
6181
8013
  *
6182
- * Two questions, in order, and they are not the same one: `plg_connections`
8014
+ * Two questions, in order, and they are not the same one: `connections`
6183
8015
  * says whether the clinic authorized Google, and ./health.ts says whether the
6184
8016
  * sync is actually working. Asking only the second is what let a connection
6185
8017
  * read as "conectado" off a token timestamp; asking only the first would
@@ -8153,7 +9985,7 @@ function createAgendaPlugin(options) {
8153
9985
  return locations;
8154
9986
  } : void 0
8155
9987
  });
8156
- const PageComponent = () => React13.createElement(AgendaPage, { config, provider: provider2, store });
9988
+ const PageComponent = () => React11.createElement(AgendaPage, { config, provider: provider2, store });
8157
9989
  return {
8158
9990
  id: "agenda",
8159
9991
  defaultAgentRole: "frontdesk",
@@ -8179,7 +10011,7 @@ function createAgendaPlugin(options) {
8179
10011
  // Os FATOS da agenda — só expostos enquanto o módulo está ATIVO.
8180
10012
  events: buildAgendaDomainEvents(),
8181
10013
  declaredFeatures: [
8182
- { id: "appointments", label: config.labels.pageTitle, group: config.labels.pageTitle },
10014
+ { id: "agenda", label: config.labels.pageTitle, group: config.labels.pageTitle },
8183
10015
  { id: "agenda.schedules", label: "Schedule Management", group: config.labels.pageTitle }
8184
10016
  ],
8185
10017
  // Recurring monthly quota — counts appointment rows created this month.
@@ -8195,7 +10027,7 @@ function createAgendaPlugin(options) {
8195
10027
  name: "Appointment",
8196
10028
  namePlural: "Appointments",
8197
10029
  icon: "Calendar",
8198
- permission: { feature: "appointments", action: "read" },
10030
+ permission: { feature: "agenda", action: "read" },
8199
10031
  // Keys are the PROVIDER's output shape (camelCase) — what filters/
8200
10032
  // dateField/groupBy match against, not the raw DB columns.
8201
10033
  fields: [
@@ -8230,6 +10062,16 @@ function createAgendaPlugin(options) {
8230
10062
  audits: true
8231
10063
  }
8232
10064
  ],
10065
+ // GATED ON `agenda.*`, NOT `appointments.*`.
10066
+ //
10067
+ // Both exist in the permission catalog for the same capability, and the two
10068
+ // never aliased: RLS on public.appointments/schedules is keyed on `agenda.*`
10069
+ // (migration 043) and 37 roles hold `agenda.read`, while only the 13 owner
10070
+ // roles — which got everything — hold `appointments.read`. So the UI was
10071
+ // hiding from a receptionist exactly what the database would have let her
10072
+ // read: no Agenda, no Check-in, and "acesso restrito" on a screen built for
10073
+ // her. app.v1_permission_map records this divergence and maps V1's
10074
+ // `checkin_dashboard` to `agenda.read`.
8233
10075
  navigation: [
8234
10076
  {
8235
10077
  section: options?.navSection ?? "main",
@@ -8237,17 +10079,45 @@ function createAgendaPlugin(options) {
8237
10079
  label: config.labels.pageTitle,
8238
10080
  route: "/agenda",
8239
10081
  icon: "Calendar",
8240
- permission: { feature: "appointments", action: "read" }
8241
- }
10082
+ permission: { feature: "agenda", action: "read" }
10083
+ },
10084
+ // AO LADO da Agenda, e não dentro dela: a recepção abre esta tela e fica
10085
+ // nela a manhã inteira. A rota é `/checkin` e não `/agenda/checkin` de
10086
+ // propósito — a casca agrupa num menu suspenso tudo que compartilha o
10087
+ // prefixo da rota, e sob `/agenda` o check-in virava um item escondido
10088
+ // atrás de um clique na Agenda, que é o oposto de entrada dedicada.
10089
+ ...options?.checkin ? [{
10090
+ section: options?.navSection ?? "main",
10091
+ position: (options?.navPosition ?? 2) + 0.5,
10092
+ label: "Check-in",
10093
+ route: "/checkin",
10094
+ icon: "LogIn",
10095
+ permission: { feature: "agenda", action: "read" }
10096
+ }] : []
8242
10097
  ],
8243
10098
  routes: [
8244
10099
  {
8245
10100
  path: "/agenda",
8246
10101
  component: PageComponent,
8247
- permission: { feature: "appointments", action: "read" },
10102
+ permission: { feature: "agenda", action: "read" },
8248
10103
  // Matches the ref agent_agenda_create_appointment returns.
8249
10104
  entityRoutes: ["schedule:appointment"]
8250
- }
10105
+ },
10106
+ ...options?.checkin ? [{
10107
+ path: "/checkin",
10108
+ component: (() => React11.createElement(CheckinPage, {
10109
+ config,
10110
+ provider: provider2,
10111
+ store,
10112
+ onReceive: options?.onCheckinReceive ? (b) => options.onCheckinReceive({ id: b.id, orderId: b.orderId, clientName: b.clientName }) : void 0
10113
+ // `onOpenBooking` fica de FORA de propósito: sem ele a fila abre o
10114
+ // formulário nela mesma. Mandar para /agenda tirava a recepção da
10115
+ // tela em que ela está trabalhando para corrigir um horário, e a
10116
+ // volta ficava por conta dela. O host ainda pode passar o seu
10117
+ // quando quiser outro destino.
10118
+ })),
10119
+ permission: { feature: "agenda", action: "read" }
10120
+ }] : []
8251
10121
  ],
8252
10122
  widgets: [],
8253
10123
  aiTools: [
@@ -8270,7 +10140,7 @@ function createAgendaPlugin(options) {
8270
10140
  { label: "Show me tomorrow's appointments" },
8271
10141
  { label: "Who has the most bookings this week?" }
8272
10142
  ],
8273
- permission: { feature: "appointments", action: "read" }
10143
+ permission: { feature: "agenda", action: "read" }
8274
10144
  },
8275
10145
  {
8276
10146
  id: "agenda.create-appointment",
@@ -8295,7 +10165,7 @@ function createAgendaPlugin(options) {
8295
10165
  suggestions: [
8296
10166
  { label: "Book a haircut for Sarah tomorrow at 10am" }
8297
10167
  ],
8298
- permission: { feature: "appointments", action: "create" }
10168
+ permission: { feature: "agenda", action: "create" }
8299
10169
  },
8300
10170
  {
8301
10171
  id: "agenda.update-appointment",
@@ -8324,7 +10194,7 @@ function createAgendaPlugin(options) {
8324
10194
  { label: "Remarca o Gustavo para 16:30" },
8325
10195
  { label: "Cancela o atendimento das 10h" }
8326
10196
  ],
8327
- permission: { feature: "appointments", action: "edit" }
10197
+ permission: { feature: "agenda", action: "edit" }
8328
10198
  },
8329
10199
  {
8330
10200
  id: "agenda.check-availability",
@@ -8346,7 +10216,7 @@ function createAgendaPlugin(options) {
8346
10216
  { label: "When is Ana available tomorrow?" },
8347
10217
  { label: "Find a 90-minute slot for Carlos this week" }
8348
10218
  ],
8349
- permission: { feature: "appointments", action: "read" }
10219
+ permission: { feature: "agenda", action: "read" }
8350
10220
  }
8351
10221
  ],
8352
10222
  registries,
@@ -8357,9 +10227,9 @@ function createAgendaPlugin(options) {
8357
10227
  icon: "Calendar",
8358
10228
  // Standard layout: General | Integrations (Google Calendar addon) | Properties.
8359
10229
  component: (() => {
8360
- const AgendaSettingsTab = () => React13.createElement(PluginSettingsPanel, {
10230
+ const AgendaSettingsTab = () => React11.createElement(PluginSettingsPanel, {
8361
10231
  title: "Agenda",
8362
- generalSettings: React13.createElement(AgendaGeneralSettings),
10232
+ generalSettings: React11.createElement(AgendaGeneralSettings),
8363
10233
  registries,
8364
10234
  hostPluginId: "agenda",
8365
10235
  routeBase: "/settings/agenda"
@@ -8368,17 +10238,18 @@ function createAgendaPlugin(options) {
8368
10238
  return AgendaSettingsTab;
8369
10239
  })(),
8370
10240
  order: 5,
8371
- permission: { feature: "appointments", action: "read" }
8372
- }
8373
- ],
8374
- migrations: [
8375
- {
8376
- id: "agenda-001-public-booking",
8377
- version: "0.3.0",
8378
- sql: MIGRATION_001_PUBLIC_BOOKING,
8379
- description: "Booking read model (v_appointments) + anon public booking surface (v_public_services, get_available_slots, create_public_booking) + order_items duration/assignee columns"
10241
+ permission: { feature: "agenda", action: "read" }
8380
10242
  }
8381
10243
  ],
10244
+ // Every file, not just the first. The manifest declared one of twenty-one
10245
+ // until Phase 2 put this plugin in the canonical chain — a plugin whose SQL
10246
+ // is outside its manifest is a plugin whose tables are a coincidence.
10247
+ migrations: MIGRATIONS.map((m) => ({
10248
+ id: `agenda-${m.id}`,
10249
+ version: "1.0.0",
10250
+ sql: m.sql,
10251
+ description: m.id
10252
+ })),
8382
10253
  // Google Calendar is the connector this module's addon already ships;
8383
10254
  // Calendly and WhatsApp are the two the Fayz plugin page has been
8384
10255
  // promising since the catalogue was written.