@fayz-ai/plugin-agenda 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
- package/dist/{chunk-ISLJXP7O.js → chunk-PDYBAVXU.js} +6 -4
- package/dist/chunk-PDYBAVXU.js.map +1 -0
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/CalendarSidebar.d.ts.map +1 -1
- package/dist/components/CheckinDialog.d.ts +24 -0
- package/dist/components/CheckinDialog.d.ts.map +1 -0
- package/dist/components/FilterRail.d.ts +42 -0
- package/dist/components/FilterRail.d.ts.map +1 -0
- package/dist/components/MiniCalendar.d.ts +1 -8
- package/dist/components/MiniCalendar.d.ts.map +1 -1
- package/dist/components/PaymentStatusBadge.d.ts +4 -9
- package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
- package/dist/components/StatusBadge.d.ts +6 -0
- package/dist/components/StatusBadge.d.ts.map +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +2 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2343 -472
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/connectorDef.d.ts +1 -1
- package/dist/integrations/google-calendar/credentials.d.ts +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +7 -21
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/index.js +2 -2
- package/dist/store.d.ts +8 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +17 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts +16 -0
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/dist/views/CheckinPage.d.ts +12 -0
- package/dist/views/CheckinPage.d.ts.map +1 -0
- package/dist/views/CheckinView.d.ts +13 -0
- package/dist/views/CheckinView.d.ts.map +1 -0
- package/package.json +5 -5
- package/dist/chunk-ISLJXP7O.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createMockAgendaProvider, createSupabasePublicBookingProvider } from './chunk-
|
|
2
|
-
export { createMockAgendaProvider } from './chunk-
|
|
3
|
-
import
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
return
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
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,
|
|
2841
|
-
[schedules,
|
|
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
|
-
|
|
2998
|
-
|
|
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
|
|
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__ */
|
|
3183
|
-
|
|
3260
|
+
/* @__PURE__ */ jsx(FilterSection, { title: t2("agenda.sidebar.agendas"), children: /* @__PURE__ */ jsx(
|
|
3261
|
+
ProfessionalFilterList,
|
|
3184
3262
|
{
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
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__ */
|
|
3205
|
-
|
|
3275
|
+
/* @__PURE__ */ jsx(FilterSection, { title: t2("agenda.sidebar.status"), children: /* @__PURE__ */ jsx(
|
|
3276
|
+
StatusFilterList,
|
|
3206
3277
|
{
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
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
|
-
|
|
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("
|
|
3545
|
-
|
|
3546
|
-
|
|
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.
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
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
|
-
|
|
3744
|
-
|
|
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("
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
4468
|
+
const statuses2 = await financialBridge.resolvePaymentStatuses(orderIds);
|
|
4025
4469
|
bookings = bookings.map((b) => {
|
|
4026
4470
|
if (!b.orderId) return b;
|
|
4027
|
-
const summary =
|
|
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
|
-
|
|
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({
|
|
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
|
|
4760
|
-
-- plugin-agenda
|
|
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
|
-
--
|
|
4763
|
-
--
|
|
4764
|
-
--
|
|
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
|
-
--
|
|
4772
|
-
--
|
|
4773
|
-
--
|
|
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
|
-
--
|
|
4776
|
-
--
|
|
4777
|
-
--
|
|
4778
|
-
--
|
|
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
|
-
--
|
|
4782
|
-
--
|
|
4783
|
-
--
|
|
4784
|
-
--
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
-- \
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
ON
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4872
|
-
|
|
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
|
|
4876
|
-
RAISE EXCEPTION '
|
|
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
|
-
|
|
4879
|
-
|
|
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
|
|
4883
|
-
|
|
4884
|
-
|
|
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
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
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
|
|
4912
|
-
FROM
|
|
4913
|
-
WHERE
|
|
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 =
|
|
5627
|
+
AND b.assignee_id = v_professional
|
|
4917
5628
|
AND b.status NOT IN ('cancelled', 'no_show')
|
|
4918
|
-
AND b.starts_at <
|
|
4919
|
-
AND COALESCE(b.ends_at, b.starts_at) >
|
|
4920
|
-
)
|
|
4921
|
-
|
|
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
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
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
|
-
-- \
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
WHERE
|
|
4985
|
-
AND
|
|
4986
|
-
|
|
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
|
-
--
|
|
5025
|
-
--
|
|
5026
|
-
--
|
|
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
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
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
|
-
|
|
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
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
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 [
|
|
5138
|
-
supabase.from("
|
|
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 =
|
|
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.
|
|
6995
|
+
invoiceId: order.order_id,
|
|
5164
6996
|
referenceNumber: order.reference_number,
|
|
5165
|
-
status:
|
|
5166
|
-
totalAmount: Number(order.
|
|
5167
|
-
paidAmount: Number(
|
|
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("
|
|
5200
|
-
if (!order || !order.
|
|
5201
|
-
const { data: staff } = await supabase.from("v_professional_rates").select("commission_rate").eq("id", order.
|
|
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.
|
|
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.
|
|
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 — `
|
|
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: `
|
|
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 = () =>
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 = () =>
|
|
10230
|
+
const AgendaSettingsTab = () => React11.createElement(PluginSettingsPanel, {
|
|
8361
10231
|
title: "Agenda",
|
|
8362
|
-
generalSettings:
|
|
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: "
|
|
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.
|