@fayz-ai/plugin-agenda 0.22.0 → 0.23.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/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createMockAgendaProvider, createSupabasePublicBookingProvider } from './chunk-A77PUFY5.js';
2
2
  export { createMockAgendaProvider } from './chunk-A77PUFY5.js';
3
3
  import React23, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from 'react';
4
- import { tl, recordRailFields, RECORD_GROUP, activeFacet, connectionStatus, createConnectionStore, getActiveTenantId, createSafeDataProvider, todayLocal, getSupabaseClientOptional, registerTranslations, registerMessageDefaults, getActiveUnitId, assertWrote, connectorRuntimeToken, CONNECTOR_RUNTIME_TOKEN_HEADER, emitDataChanged, globalCache, usePluginRuntimeOptional, useTranslation, useActiveUnitId, emitDomainEvent, getCurrentLocale, localDateKey, useDataChanged, formatCurrency, resolveDataProvider } from '@fayz-ai/core';
5
- import { createPluginContext, fayzConnectorOAuth, UNIT_DETAIL_ZONES, setScheduleBlockConfig, getScheduleBlockConfig, registerPersonActions, PluginSettingsPanel, adminNavigateTo, createArchetypeLookup, dedup, usePluginPrefs, PersonLink, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, PermissionGate, PageActions, useRightRailStore, useFieldRules, deriveEntityKey, usePermissionOptional, openEntity, ScheduleEditor, useLimitGuard, useLookupCreate, CrudFormPage, CrudDetailPage, ContactPicker, PaymentStatusBadge as PaymentStatusBadge$1, RightRailPage, invalidateLimit } from '@fayz-ai/admin';
4
+ import { tl, recordRailFields, RECORD_GROUP, activeFacet, connectionStatus, createConnectionStore, getActiveTenantId, createSafeDataProvider, todayLocal, getSupabaseClientOptional, registerTranslations, registerMessageDefaults, connectorRuntimeToken, CONNECTOR_RUNTIME_TOKEN_HEADER, getActiveUnitId, assertWrote, emitDataChanged, globalCache, usePluginRuntimeOptional, useTranslation, useActiveUnitId, emitDomainEvent, getCurrentLocale, localDateKey, useDataChanged, describeError, formatCurrency, resolveDataProvider } from '@fayz-ai/core';
5
+ import { createPluginContext, fayzConnectorOAuth, UNIT_DETAIL_ZONES, setScheduleBlockConfig, getScheduleBlockConfig, registerPersonActions, PluginSettingsPanel, adminNavigateTo, createArchetypeLookup, dedup, usePluginPrefs, PersonLink, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, usePermissionOptional, PageActions, useRightRailStore, useFieldRules, deriveEntityKey, openEntity, ScheduleEditor, useLimitGuard, useLookupCreate, CrudFormPage, CrudDetailPage, ContactPicker, PaymentStatusBadge as PaymentStatusBadge$1, RightRailPage, 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,8 +10,8 @@ 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 { CheckCircle, Stethoscope, RotateCcw, Check, CircleDashed, Layers, ArrowRight, Play, Users, Activity, RefreshCw, Clock3, CalendarPlus, Loader2, AlertCircle, X, CheckCircle2, Ban, Plus, Settings, ChevronLeft, ChevronRight, ChevronDown, CircleDollarSign, LogIn, CalendarDays, UserRound, Package, Clock, Mic, MapPin, CalendarX, UserX, Search, CalendarOff, Pencil, Trash2, User, FileText, AlertTriangle, DollarSign, HandCoins, CheckSquare, Calendar, History, ExternalLink, DoorOpen, Briefcase, CircleDot, MessageCircle, Phone, AlarmClock, ArrowLeft, Link2, Receipt, ClipboardCheck, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, PackageCheck, Sparkles } from 'lucide-react';
14
- import { Card, CardHeader, CardTitle, CardDescription, CardContent, Skeleton, Switch, Input, Button, toast, FilterRail, AdaptiveFilter, FilterCheckRow, TooltipProvider, ModulePage, Avatar, AvatarImage, AvatarFallback, Badge, Checkbox, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, PersonAvatar, abbreviatePersonName, INLINE_FILTER_MAX, PersonMultiPicker, SegmentedControl, MiniCalendar, PageHeaderActions, ConfirmDialog, RailWorkspace, Tooltip, TooltipTrigger, TooltipContent, SubpageHeader, ModalTask, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, PersonPicker, Tabs, TabsList, TabsTrigger, DatePicker, TimePicker, ModalFooter, ModalTaskHost, SearchCombobox, ModalTaskHeader, Dropdown, DropdownTrigger, DropdownContent, DropdownItem, cn, normalizePhone, DotBadge } from '@fayz-ai/ui';
13
+ import { CheckCircle, Stethoscope, RotateCcw, Check, CircleDashed, Layers, ArrowRight, Play, Radio, Monitor, BarChart3, SlidersHorizontal, Download, Loader2, LogIn, Coffee, Undo2, CheckCircle2, LogOut, AlertCircle, RefreshCw, CalendarPlus, X, Ban, Clock3, Plus, Settings, ChevronLeft, ChevronRight, ChevronDown, CircleDollarSign, CalendarDays, UserRound, Package, Clock, Mic, BellRing, UserPlus, History, ArrowUp, ArrowDown, Trash2, Sparkles, CalendarX, UserX, Search, CalendarOff, Pencil, User, MapPin, FileText, AlertTriangle, DollarSign, HandCoins, CheckSquare, Calendar, ExternalLink, Users, DoorOpen, Briefcase, CircleDot, MessageCircle, Phone, AlarmClock, ArrowLeft, Link2, Receipt, ClipboardCheck, MoreVertical, ArrowUpToLine, Info, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, PackageCheck, Maximize2 } from 'lucide-react';
14
+ import { Card, CardHeader, CardTitle, CardDescription, CardContent, Skeleton, Switch, Input, Button, toast, FilterRail, AdaptiveFilter, FilterCheckRow, TooltipProvider, Badge, ModulePage, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, SubpageHeader, DatePicker, MultiSelect, Dropdown, DropdownTrigger, DropdownContent, DropdownLabel, DropdownSub, DropdownSubTrigger, DropdownSubContent, DropdownItem, DropdownSeparator, Checkbox, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, PersonAvatar, abbreviatePersonName, INLINE_FILTER_MAX, PersonMultiPicker, SegmentedControl, MiniCalendar, PageHeaderActions, ConfirmDialog, RailWorkspace, Tooltip, TooltipTrigger, TooltipContent, ModalTask, cn, Tabs, TabsList, TabsTrigger, TabsContent, createXlsxReport, PersonPicker, TimePicker, ModalTaskHost, SearchCombobox, ModalTaskHeader, normalizePhone, DotBadge } from '@fayz-ai/ui';
15
15
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
16
16
  import { createPortal } from 'react-dom';
17
17
  import { listAttendanceContracts, ensureAttendanceContracts, ContractSignPane, FormFillDialog, PersonFormsAlert } from '@fayz-ai/plugin-forms';
@@ -351,7 +351,7 @@ function resolveConfig(options) {
351
351
  minAdvanceHours: options?.scheduleBlockDefaults?.minAdvanceHours ?? 0,
352
352
  maxAdvanceDays: options?.scheduleBlockDefaults?.maxAdvanceDays ?? 60
353
353
  },
354
- defaultCalendarView: options?.defaultCalendarView ?? "resourceTimeGridWeek"
354
+ defaultCalendarView: options?.defaultCalendarView ?? "resourceTimeGridDay"
355
355
  };
356
356
  }
357
357
  var ctx = createPluginContext("AgendaPage");
@@ -4218,7 +4218,7 @@ function bookingToEvent(booking, statusColors, visibleResourceIds = [], serviceL
4218
4218
  // has no assignee. Availability treats it as busy for every professional, so
4219
4219
  // resource views must render it in every visible lane instead of dropping it.
4220
4220
  ...booking.professionalId ? { resourceId: booking.professionalId } : visibleResourceIds.length > 0 ? { resourceIds: visibleResourceIds } : {},
4221
- title: booking.clientName ?? "Unknown",
4221
+ title: booking.clientName ?? "Sem cliente",
4222
4222
  start: booking.startsAt,
4223
4223
  end: booking.endsAt ?? void 0,
4224
4224
  backgroundColor: kindColor ?? color + "30",
@@ -4424,7 +4424,7 @@ function CalendarView() {
4424
4424
  const fcLocale = currentLocale === "pt-BR" ? ptBrLocale : void 0;
4425
4425
  const calendarRef = useRef(null);
4426
4426
  const prefs = usePluginPrefs("agenda", {
4427
- calendarView: config.defaultCalendarView ?? "resourceTimeGridWeek",
4427
+ calendarView: config.defaultCalendarView ?? "resourceTimeGridDay",
4428
4428
  // Como a SEMANA se agrupa: 'day' põe o dia em cima e as pessoas dentro
4429
4429
  // dele; 'professional' inverte. 'auto' é o padrão e deixa a agenda decidir
4430
4430
  // pelo tamanho da equipe — ver `weekGrouping` mais abaixo.
@@ -5366,6 +5366,7 @@ function CalendarView() {
5366
5366
  ] });
5367
5367
  }, [nextProfessionalId]);
5368
5368
  const eventContent = useCallback((arg) => {
5369
+ if (arg.event.display === "background") return null;
5369
5370
  if (arg.event.extendedProps?.isLocationLabel) {
5370
5371
  return /* @__PURE__ */ jsx("div", { className: "px-1.5 py-0.5 overflow-hidden", children: /* @__PURE__ */ jsx("p", { className: "text-[10px] font-medium truncate", children: arg.event.title }) });
5371
5372
  }
@@ -9290,7 +9291,7 @@ function AttendanceRuler({ booking, open, onClose, onFinished, frame = "modal" }
9290
9291
  setErro(null);
9291
9292
  }
9292
9293
  } catch (e) {
9293
- if (vivo) setErro(e instanceof Error ? e.message : "N\xE3o foi poss\xEDvel ler a r\xE9gua.");
9294
+ if (vivo) setErro(describeError(e, "N\xE3o foi poss\xEDvel ler a r\xE9gua."));
9294
9295
  }
9295
9296
  })();
9296
9297
  return () => {
@@ -15805,6 +15806,1769 @@ END $function$;
15805
15806
  REVOKE ALL ON FUNCTION public.agenda_turn_queue_configure(uuid, boolean, boolean, integer) FROM PUBLIC, anon;
15806
15807
  GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_configure(uuid, boolean, boolean, integer) TO authenticated, service_role;
15807
15808
  `;
15809
+ var MIGRATION_021_A_VEZ_POR_AREA = `-- A lista da vez do V1 (BeautySoft, 20261114100000 + 20261114110000): uma fila
15810
+ -- por \xC1REA dentro da unidade, aus\xEAncia curta que guarda o lugar, a recep\xE7\xE3o
15811
+ -- conduzindo a fila (chamar, come\xE7ou, terminou, mover, tirar), servi\xE7o r\xE1pido
15812
+ -- que n\xE3o tira da vez e a fila recome\xE7ando todo dia.
15813
+ --
15814
+ -- \xC1reas, \xE1rea fixada e servi\xE7os r\xE1pidos s\xE3o da REDE (como no V1); o resto \xE9 da
15815
+ -- unidade e mora na linha de plg_agenda_turn_queues desde 016/020.
15816
+ -- Aditivo e idempotente: nada some, 016/017/020 continuam respondendo.
15817
+
15818
+ -- 1) Configura\xE7\xE3o da rede -------------------------------------------------------
15819
+ CREATE TABLE IF NOT EXISTS public.plg_agenda_turn_areas (
15820
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15821
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
15822
+ key text NOT NULL CHECK (key ~ '^[a-z0-9_]{1,30}$'),
15823
+ name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 40),
15824
+ color text NOT NULL DEFAULT 'slate'
15825
+ CHECK (color IN ('amber', 'green', 'blue', 'pink', 'purple', 'orange', 'teal', 'slate')),
15826
+ keywords text[] NOT NULL DEFAULT '{}',
15827
+ sort_order integer NOT NULL DEFAULT 0,
15828
+ is_active boolean NOT NULL DEFAULT true,
15829
+ created_at timestamptz NOT NULL DEFAULT now(),
15830
+ updated_at timestamptz NOT NULL DEFAULT now(),
15831
+ UNIQUE (tenant_id, key)
15832
+ );
15833
+
15834
+ CREATE TABLE IF NOT EXISTS public.plg_agenda_turn_professional_areas (
15835
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
15836
+ professional_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,
15837
+ area_key text NOT NULL,
15838
+ updated_at timestamptz NOT NULL DEFAULT now(),
15839
+ PRIMARY KEY (tenant_id, professional_id)
15840
+ );
15841
+
15842
+ CREATE TABLE IF NOT EXISTS public.plg_agenda_turn_quick_services (
15843
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
15844
+ product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
15845
+ created_at timestamptz NOT NULL DEFAULT now(),
15846
+ PRIMARY KEY (tenant_id, product_id)
15847
+ );
15848
+
15849
+ ALTER TABLE public.plg_agenda_turn_areas ENABLE ROW LEVEL SECURITY;
15850
+ ALTER TABLE public.plg_agenda_turn_areas FORCE ROW LEVEL SECURITY;
15851
+ ALTER TABLE public.plg_agenda_turn_professional_areas ENABLE ROW LEVEL SECURITY;
15852
+ ALTER TABLE public.plg_agenda_turn_professional_areas FORCE ROW LEVEL SECURITY;
15853
+ ALTER TABLE public.plg_agenda_turn_quick_services ENABLE ROW LEVEL SECURITY;
15854
+ ALTER TABLE public.plg_agenda_turn_quick_services FORCE ROW LEVEL SECURITY;
15855
+
15856
+ DO $$ BEGIN
15857
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_areas' AND policyname = 'plg_agenda_turn_areas_read') THEN
15858
+ CREATE POLICY plg_agenda_turn_areas_read ON public.plg_agenda_turn_areas
15859
+ FOR SELECT TO authenticated USING (tenant_id = app.current_tenant_id());
15860
+ END IF;
15861
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_professional_areas' AND policyname = 'plg_agenda_turn_professional_areas_read') THEN
15862
+ CREATE POLICY plg_agenda_turn_professional_areas_read ON public.plg_agenda_turn_professional_areas
15863
+ FOR SELECT TO authenticated USING (tenant_id = app.current_tenant_id());
15864
+ END IF;
15865
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_quick_services' AND policyname = 'plg_agenda_turn_quick_services_read') THEN
15866
+ CREATE POLICY plg_agenda_turn_quick_services_read ON public.plg_agenda_turn_quick_services
15867
+ FOR SELECT TO authenticated USING (tenant_id = app.current_tenant_id());
15868
+ END IF;
15869
+ END $$;
15870
+
15871
+ REVOKE ALL ON public.plg_agenda_turn_areas, public.plg_agenda_turn_professional_areas, public.plg_agenda_turn_quick_services FROM PUBLIC, anon, authenticated;
15872
+ GRANT SELECT ON public.plg_agenda_turn_areas, public.plg_agenda_turn_professional_areas, public.plg_agenda_turn_quick_services TO authenticated;
15873
+
15874
+ -- 2) Unidade, entradas e trilha -------------------------------------------------
15875
+ ALTER TABLE public.plg_agenda_turn_queues
15876
+ ADD COLUMN IF NOT EXISTS follows_appointments boolean NOT NULL DEFAULT true,
15877
+ ADD COLUMN IF NOT EXISTS self_checkin boolean NOT NULL DEFAULT true;
15878
+
15879
+ ALTER TABLE public.plg_agenda_turn_entries
15880
+ ADD COLUMN IF NOT EXISTS area_key text,
15881
+ ADD COLUMN IF NOT EXISTS away_reason text,
15882
+ ADD COLUMN IF NOT EXISTS joined_at timestamptz;
15883
+
15884
+ ALTER TABLE public.plg_agenda_turn_events
15885
+ ADD COLUMN IF NOT EXISTS area_key text;
15886
+
15887
+ -- 'away' = saiu e guarda o lugar. 'unavailable' segue sendo "fora da fila".
15888
+ ALTER TABLE public.plg_agenda_turn_entries DROP CONSTRAINT IF EXISTS plg_agenda_turn_entries_status_check;
15889
+ ALTER TABLE public.plg_agenda_turn_entries ADD CONSTRAINT plg_agenda_turn_entries_status_check
15890
+ CHECK (status IN ('available', 'away', 'serving', 'unavailable'));
15891
+
15892
+ UPDATE public.plg_agenda_turn_entries
15893
+ SET joined_at = coalesce(available_at, state_changed_at)
15894
+ WHERE joined_at IS NULL AND status <> 'unavailable';
15895
+
15896
+ -- 3) Helpers ----------------------------------------------------------------------
15897
+ CREATE OR REPLACE FUNCTION public.agenda_turn__norm(p text)
15898
+ RETURNS text LANGUAGE sql IMMUTABLE SET search_path = '' AS $$
15899
+ SELECT translate(lower(coalesce(p, '')),
15900
+ '\xE1\xE0\xE2\xE3\xE4\xE9\xE8\xEA\xEB\xED\xEC\xEE\xEF\xF3\xF2\xF4\xF5\xF6\xFA\xF9\xFB\xFC\xE7\xF1', 'aaaaaeeeeiiiiooooouuuucn');
15901
+ $$;
15902
+
15903
+ CREATE OR REPLACE FUNCTION public.agenda_turn__area_defs(p_tenant uuid)
15904
+ RETURNS TABLE(key text, name text, color text, keywords text[], sort_order integer)
15905
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
15906
+ WITH cad AS (
15907
+ SELECT a.key, a.name, a.color, a.keywords, a.sort_order
15908
+ FROM public.plg_agenda_turn_areas a
15909
+ WHERE a.tenant_id = p_tenant AND a.is_active
15910
+ ), padrao(key, name, color, keywords, sort_order) AS (
15911
+ VALUES
15912
+ ('cabelo', 'Cabelo', 'amber', ARRAY['cabel', 'color', 'escov', 'penteado', 'barb'], 1),
15913
+ ('unhas', 'Unhas', 'green', ARRAY['manicur', 'pedicur', 'unha'], 2),
15914
+ ('estetica', 'Est\xE9tica', 'blue', ARRAY['estet', 'depil', 'sobrancel', 'massag', 'cili', 'maqui'], 3)
15915
+ )
15916
+ SELECT * FROM cad
15917
+ UNION ALL
15918
+ SELECT * FROM padrao WHERE NOT EXISTS (SELECT 1 FROM cad)
15919
+ UNION ALL
15920
+ SELECT 'geral', 'Outros', 'slate', ARRAY[]::text[], 99
15921
+ WHERE NOT EXISTS (SELECT 1 FROM cad WHERE cad.key = 'geral');
15922
+ $$;
15923
+
15924
+ -- The specialty arrives as a JSON array string ('["Cabelereiro","Colorista"]')
15925
+ -- from the V1 ETL, or as plain text typed in the V2 form.
15926
+ CREATE OR REPLACE FUNCTION public.agenda_turn__auto_area_of(p_tenant uuid, p_person uuid)
15927
+ RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
15928
+ DECLARE
15929
+ v_spec text;
15930
+ v_tokens text[];
15931
+ v_key text;
15932
+ BEGIN
15933
+ SELECT btrim(coalesce(nullif(p.metadata->'professional'->>'specialty', ''),
15934
+ nullif(p.metadata->'professional'->>'job_title', ''),
15935
+ nullif(p.custom_fields->>'profession', ''), ''))
15936
+ INTO v_spec FROM public.people p WHERE p.id = p_person AND p.tenant_id = p_tenant;
15937
+ IF coalesce(v_spec, '') = '' THEN RETURN 'geral'; END IF;
15938
+ BEGIN
15939
+ IF left(v_spec, 1) = '[' THEN
15940
+ SELECT array_agg(public.agenda_turn__norm(x)) INTO v_tokens FROM jsonb_array_elements_text(v_spec::jsonb) x;
15941
+ END IF;
15942
+ EXCEPTION WHEN others THEN v_tokens := NULL;
15943
+ END;
15944
+ IF v_tokens IS NULL THEN v_tokens := ARRAY[public.agenda_turn__norm(v_spec)]; END IF;
15945
+
15946
+ SELECT d.key INTO v_key FROM public.agenda_turn__area_defs(p_tenant) d
15947
+ WHERE EXISTS (SELECT 1 FROM unnest(d.keywords) pat, unnest(v_tokens) tok
15948
+ WHERE pat <> '' AND tok LIKE '%' || public.agenda_turn__norm(pat) || '%')
15949
+ ORDER BY d.sort_order, d.key LIMIT 1;
15950
+ RETURN coalesce(v_key, 'geral');
15951
+ END $$;
15952
+
15953
+ -- A pinned area wins over the specialty, as long as that area still exists.
15954
+ CREATE OR REPLACE FUNCTION public.agenda_turn__area_of(p_tenant uuid, p_person uuid)
15955
+ RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
15956
+ SELECT coalesce(
15957
+ (SELECT pa.area_key FROM public.plg_agenda_turn_professional_areas pa
15958
+ WHERE pa.tenant_id = p_tenant AND pa.professional_id = p_person
15959
+ AND EXISTS (SELECT 1 FROM public.agenda_turn__area_defs(p_tenant) d WHERE d.key = pa.area_key)),
15960
+ public.agenda_turn__auto_area_of(p_tenant, p_person));
15961
+ $$;
15962
+
15963
+ CREATE OR REPLACE FUNCTION public.agenda_turn__tz(p_tenant uuid, p_unit uuid)
15964
+ RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
15965
+ SELECT coalesce(
15966
+ (SELECT nullif(u.timezone, '') FROM public.v_units u WHERE u.id = p_unit),
15967
+ (SELECT nullif(t.settings->>'timezone', '') FROM public.tenants t WHERE t.id = p_tenant),
15968
+ 'America/Sao_Paulo');
15969
+ $$;
15970
+
15971
+ -- Yesterday's queue does not survive the night: entries from a previous local
15972
+ -- day go out of the queue and leave a 'reset' in the trail.
15973
+ CREATE OR REPLACE FUNCTION public.agenda_turn__purge_stale(p_queue uuid, p_tz text)
15974
+ RETURNS void LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
15975
+ BEGIN
15976
+ WITH velhas AS (
15977
+ UPDATE public.plg_agenda_turn_entries e
15978
+ SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,
15979
+ state_changed_at = now(), updated_at = now()
15980
+ WHERE e.queue_id = p_queue AND e.status <> 'unavailable'
15981
+ AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE p_tz)::date
15982
+ < (now() AT TIME ZONE p_tz)::date
15983
+ RETURNING e.tenant_id, e.unit_id, e.professional_id, e.area_key, e.position
15984
+ )
15985
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, area_key, payload)
15986
+ SELECT v.tenant_id, p_queue, v.unit_id, v.professional_id, 'reset', 'automatic', v.area_key,
15987
+ jsonb_build_object('from_position', v.position)
15988
+ FROM velhas v;
15989
+ END $$;
15990
+
15991
+ CREATE OR REPLACE FUNCTION public.agenda_turn__renumber(p_queue uuid, p_area text)
15992
+ RETURNS void LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
15993
+ UPDATE public.plg_agenda_turn_entries e SET position = o.rn
15994
+ FROM (SELECT id, row_number() OVER (ORDER BY position, joined_at, id) AS rn
15995
+ FROM public.plg_agenda_turn_entries
15996
+ WHERE queue_id = p_queue AND area_key IS NOT DISTINCT FROM p_area
15997
+ AND status IN ('available', 'away')) o
15998
+ WHERE e.id = o.id AND e.position IS DISTINCT FROM o.rn;
15999
+ $$;
16000
+
16001
+ CREATE OR REPLACE FUNCTION public.agenda_turn__next_position(p_queue uuid, p_area text)
16002
+ RETURNS bigint LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
16003
+ SELECT coalesce(max(position), 0) + 1 FROM public.plg_agenda_turn_entries
16004
+ WHERE queue_id = p_queue AND area_key IS NOT DISTINCT FROM p_area AND status IN ('available', 'away');
16005
+ $$;
16006
+
16007
+ -- Bookable people of this unit (or of the whole network), as the agenda shows them.
16008
+ CREATE OR REPLACE FUNCTION public.agenda_turn__in_unit(p_tenant uuid, p_unit uuid, p_person uuid)
16009
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
16010
+ SELECT EXISTS (
16011
+ SELECT 1 FROM public.v_bookable_people b
16012
+ WHERE b.id = p_person AND b.tenant_id = p_tenant AND b.is_active AND b.is_bookable
16013
+ AND b.kind <> 'customer'
16014
+ AND (b.unit_ids IS NULL OR p_unit = ANY (b.unit_ids)));
16015
+ $$;
16016
+
16017
+ CREATE OR REPLACE FUNCTION public.agenda_turn__queue(p_tenant uuid, p_unit uuid)
16018
+ RETURNS public.plg_agenda_turn_queues LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
16019
+ SELECT * FROM public.plg_agenda_turn_queues q
16020
+ WHERE q.tenant_id = p_tenant AND q.unit_id = p_unit AND q.queue_key = 'walk_in';
16021
+ $$;
16022
+
16023
+ -- 4) Quadro -------------------------------------------------------------------------
16024
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_board(p_unit_id uuid)
16025
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
16026
+ DECLARE
16027
+ v_tenant uuid := app.current_tenant_id();
16028
+ v_queue public.plg_agenda_turn_queues;
16029
+ v_tz text;
16030
+ v_day date;
16031
+ v_lat numeric;
16032
+ v_lng numeric;
16033
+ v_result jsonb;
16034
+ BEGIN
16035
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16036
+ IF p_unit_id IS NULL THEN RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023'; END IF;
16037
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN
16038
+ RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';
16039
+ END IF;
16040
+ v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);
16041
+ v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);
16042
+ v_day := (now() AT TIME ZONE v_tz)::date;
16043
+ SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;
16044
+
16045
+ WITH ent AS (
16046
+ SELECT e.*, row_number() OVER (PARTITION BY e.area_key, (e.status IN ('available', 'away'))
16047
+ ORDER BY e.position, e.joined_at, e.id) AS rnk
16048
+ FROM public.plg_agenda_turn_entries e
16049
+ WHERE e.queue_id = v_queue.id AND e.status IN ('available', 'away', 'serving')
16050
+ AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = v_day
16051
+ ), appts AS (
16052
+ SELECT a.assignee_id AS professional_id, count(*)::int AS n,
16053
+ min(a.starts_at) FILTER (WHERE a.starts_at >= now() AND a.status NOT IN ('completed', 'cancelled', 'no_show')) AS proximo
16054
+ FROM public.appointments a
16055
+ WHERE a.tenant_id = v_tenant AND a.assignee_id IS NOT NULL
16056
+ AND coalesce(a.unit_id, a.location_id) = p_unit_id
16057
+ AND (a.starts_at AT TIME ZONE v_tz)::date = v_day
16058
+ AND a.status <> 'cancelled'
16059
+ GROUP BY 1
16060
+ ), roster AS (
16061
+ SELECT p.id, p.name, p.avatar_url, public.agenda_turn__area_of(v_tenant, p.id) AS area
16062
+ FROM public.people p
16063
+ WHERE p.tenant_id = v_tenant AND p.is_active
16064
+ AND (public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)
16065
+ OR EXISTS (SELECT 1 FROM appts t WHERE t.professional_id = p.id))
16066
+ ), served AS (
16067
+ SELECT ev.professional_id,
16068
+ count(*) FILTER (WHERE ev.event_type IN ('started', 'advanced_to_service'))::int AS atendimentos
16069
+ FROM public.plg_agenda_turn_events ev
16070
+ WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day
16071
+ GROUP BY 1
16072
+ )
16073
+ SELECT jsonb_build_object(
16074
+ 'unitId', p_unit_id,
16075
+ 'today', v_day,
16076
+ 'enabled', coalesce(v_queue.is_active, false),
16077
+ 'selfCheckin', coalesce(v_queue.self_checkin, true),
16078
+ 'auto', coalesce(v_queue.follows_appointments, true),
16079
+ 'requireLocation', coalesce(v_queue.geofence_required, false),
16080
+ 'locationConfigured', v_lat IS NOT NULL AND v_lng IS NOT NULL,
16081
+ 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color) ORDER BY d.sort_order, d.key)
16082
+ FROM public.agenda_turn__area_defs(v_tenant) d), '[]'::jsonb),
16083
+ 'entries', coalesce((
16084
+ SELECT jsonb_agg(jsonb_build_object(
16085
+ 'professionalId', e.professional_id, 'name', p.name, 'avatarUrl', p.avatar_url,
16086
+ 'area', coalesce(e.area_key, 'geral'), 'status', e.status,
16087
+ 'rank', CASE WHEN e.status IN ('available', 'away') THEN e.rnk END,
16088
+ 'awayReason', e.away_reason, 'since', e.state_changed_at, 'joinedAt', e.joined_at,
16089
+ 'appointmentId', e.appointment_id,
16090
+ 'servedToday', coalesce(s.atendimentos, 0)
16091
+ ) ORDER BY e.area_key, (e.status = 'serving'), e.rnk)
16092
+ FROM ent e JOIN public.people p ON p.id = e.professional_id
16093
+ LEFT JOIN served s ON s.professional_id = e.professional_id), '[]'::jsonb),
16094
+ 'roster', coalesce((
16095
+ SELECT jsonb_agg(jsonb_build_object(
16096
+ 'professionalId', r.id, 'name', r.name, 'avatarUrl', r.avatar_url, 'area', r.area,
16097
+ 'appointmentsToday', coalesce(t.n, 0), 'nextAppointmentAt', t.proximo,
16098
+ 'servedToday', coalesce(s.atendimentos, 0)) ORDER BY r.name)
16099
+ FROM roster r
16100
+ LEFT JOIN appts t ON t.professional_id = r.id
16101
+ LEFT JOIN served s ON s.professional_id = r.id
16102
+ WHERE NOT EXISTS (SELECT 1 FROM ent e WHERE e.professional_id = r.id)), '[]'::jsonb),
16103
+ 'events', coalesce((
16104
+ SELECT jsonb_agg(x.item ORDER BY x.at DESC) FROM (
16105
+ SELECT jsonb_build_object('id', ev.id, 'professionalId', ev.professional_id, 'name', ep.name,
16106
+ 'type', ev.event_type, 'origin', ev.origin, 'area', ev.area_key,
16107
+ 'reason', coalesce(ev.reason, ev.payload->>'reason'), 'createdAt', ev.occurred_at) AS item,
16108
+ ev.occurred_at AS at
16109
+ FROM public.plg_agenda_turn_events ev
16110
+ JOIN public.people ep ON ep.id = ev.professional_id
16111
+ WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day
16112
+ ORDER BY ev.occurred_at DESC LIMIT 60) x), '[]'::jsonb)
16113
+ ) INTO v_result;
16114
+ RETURN v_result;
16115
+ END $$;
16116
+
16117
+ -- 5) A\xE7\xF5es da recep\xE7\xE3o --------------------------------------------------------------
16118
+ -- join \u2192 create \xB7 leave \u2192 delete \xB7 away/back/move/start/finish/skip/call \u2192 edit
16119
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_act(
16120
+ p_unit_id uuid,
16121
+ p_professional_id uuid,
16122
+ p_action text,
16123
+ p_position integer DEFAULT NULL,
16124
+ p_reason text DEFAULT NULL
16125
+ ) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
16126
+ DECLARE
16127
+ v_tenant uuid := app.current_tenant_id();
16128
+ v_queue public.plg_agenda_turn_queues;
16129
+ v_entry public.plg_agenda_turn_entries;
16130
+ v_need text := CASE p_action WHEN 'join' THEN 'create' WHEN 'leave' THEN 'delete' ELSE 'edit' END;
16131
+ v_reason text := nullif(btrim(coalesce(p_reason, '')), '');
16132
+ v_area text;
16133
+ v_pos bigint;
16134
+ v_ids uuid[];
16135
+ v_users uuid[];
16136
+ BEGIN
16137
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16138
+ IF p_action NOT IN ('join', 'leave', 'away', 'back', 'move', 'start', 'finish', 'skip', 'call') THEN
16139
+ RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';
16140
+ END IF;
16141
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.' || v_need, p_unit_id) THEN
16142
+ RAISE EXCEPTION 'agenda.turn_queue.% is required', v_need USING ERRCODE = '42501';
16143
+ END IF;
16144
+ IF NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = p_professional_id AND p.tenant_id = v_tenant AND p.is_active) THEN
16145
+ RAISE EXCEPTION 'turn_queue_invalid_professional' USING ERRCODE = '22023';
16146
+ END IF;
16147
+
16148
+ SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q
16149
+ WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;
16150
+ IF v_queue.id IS NULL OR NOT v_queue.is_active THEN
16151
+ RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023';
16152
+ END IF;
16153
+ PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));
16154
+
16155
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
16156
+ WHERE e.queue_id = v_queue.id AND e.professional_id = p_professional_id;
16157
+ IF v_entry.id IS NOT NULL AND v_entry.status = 'unavailable' THEN v_entry := NULL; END IF;
16158
+
16159
+ IF p_action = 'join' THEN
16160
+ IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16161
+ v_area := public.agenda_turn__area_of(v_tenant, p_professional_id);
16162
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_area);
16163
+ INSERT INTO public.plg_agenda_turn_entries
16164
+ (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, appointment_id, away_reason, updated_at)
16165
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'available', v_pos, v_area, now(), now(), now(), NULL, NULL, now())
16166
+ ON CONFLICT (queue_id, professional_id) DO UPDATE SET
16167
+ status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),
16168
+ state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();
16169
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
16170
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'joined', 'frontdesk', auth.uid(), v_area, jsonb_build_object('to_position', v_pos));
16171
+ RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area, 'position', v_pos);
16172
+ END IF;
16173
+
16174
+ IF p_action = 'call' THEN
16175
+ SELECT array_agg(DISTINCT m.user_id) INTO v_users FROM app.memberships m
16176
+ WHERE m.tenant_id = v_tenant AND m.person_id = p_professional_id AND m.active;
16177
+ IF v_users IS NOT NULL THEN
16178
+ INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id)
16179
+ SELECT v_tenant, u, 'Cliente para voc\xEA na recep\xE7\xE3o', coalesce(v_reason, 'Voc\xEA \xE9 o pr\xF3ximo da vez.'),
16180
+ 'turn_queue_call', '#/agenda', 'person', p_professional_id
16181
+ FROM unnest(v_users) u;
16182
+ END IF;
16183
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)
16184
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'called', 'frontdesk', auth.uid(),
16185
+ coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, p_professional_id)), v_reason,
16186
+ jsonb_build_object('notified', v_users IS NOT NULL));
16187
+ RETURN jsonb_build_object('ok', true, 'changed', true, 'notified', v_users IS NOT NULL);
16188
+ END IF;
16189
+
16190
+ IF v_entry.id IS NULL THEN RAISE EXCEPTION 'turn_queue_not_in_queue' USING ERRCODE = '22023'; END IF;
16191
+
16192
+ IF p_action = 'leave' THEN
16193
+ UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,
16194
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16195
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
16196
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
16197
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'left', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));
16198
+
16199
+ ELSIF p_action = 'away' THEN
16200
+ IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;
16201
+ UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),
16202
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16203
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)
16204
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'away', 'frontdesk', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'),
16205
+ jsonb_build_object('from_position', v_entry.position));
16206
+
16207
+ ELSIF p_action = 'back' THEN
16208
+ IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16209
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,
16210
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16211
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
16212
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'back', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_entry.position));
16213
+
16214
+ ELSIF p_action = 'move' THEN
16215
+ IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;
16216
+ IF p_position IS NULL OR p_position < 1 THEN RAISE EXCEPTION 'turn_queue_invalid_position' USING ERRCODE = '22023'; END IF;
16217
+ SELECT array_agg(id ORDER BY position, joined_at, id) INTO v_ids FROM public.plg_agenda_turn_entries
16218
+ WHERE queue_id = v_queue.id AND area_key IS NOT DISTINCT FROM v_entry.area_key
16219
+ AND status IN ('available', 'away') AND id <> v_entry.id;
16220
+ v_ids := coalesce(v_ids, ARRAY[]::uuid[]);
16221
+ v_pos := least(p_position, coalesce(array_length(v_ids, 1), 0) + 1);
16222
+ v_ids := v_ids[1:v_pos - 1] || v_entry.id || v_ids[v_pos:];
16223
+ UPDATE public.plg_agenda_turn_entries e SET position = o.ord, updated_at = now()
16224
+ FROM unnest(v_ids) WITH ORDINALITY o(id, ord) WHERE e.id = o.id;
16225
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
16226
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'moved', 'frontdesk', auth.uid(), v_entry.area_key,
16227
+ jsonb_build_object('from_position', v_entry.position, 'to_position', v_pos));
16228
+
16229
+ ELSIF p_action = 'start' THEN
16230
+ IF v_entry.status = 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16231
+ UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, previous_position = position,
16232
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16233
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
16234
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
16235
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'started', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));
16236
+
16237
+ ELSIF p_action = 'finish' THEN
16238
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);
16239
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL, appointment_id = NULL,
16240
+ position = v_pos, available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16241
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
16242
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
16243
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'finished', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));
16244
+
16245
+ ELSIF p_action = 'skip' THEN
16246
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)
16247
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'skipped', 'frontdesk', auth.uid(), v_entry.area_key, v_reason,
16248
+ jsonb_build_object('from_position', v_entry.position));
16249
+ END IF;
16250
+
16251
+ RETURN jsonb_build_object('ok', true, 'changed', true);
16252
+ END $$;
16253
+
16254
+ -- 6) O pr\xF3prio profissional ("Cheguei") -------------------------------------------
16255
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_state(p_unit_id uuid)
16256
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
16257
+ DECLARE
16258
+ v_tenant uuid := app.current_tenant_id();
16259
+ v_person uuid;
16260
+ v_queue public.plg_agenda_turn_queues;
16261
+ v_entry public.plg_agenda_turn_entries;
16262
+ v_tz text;
16263
+ v_rank bigint;
16264
+ BEGIN
16265
+ IF v_tenant IS NULL OR auth.uid() IS NULL OR p_unit_id IS NULL THEN RETURN NULL; END IF;
16266
+ SELECT m.person_id INTO v_person FROM app.memberships m
16267
+ WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;
16268
+ IF v_person IS NULL OR NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN RETURN NULL; END IF;
16269
+ v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);
16270
+ IF v_queue.id IS NULL OR NOT v_queue.is_active THEN RETURN NULL; END IF;
16271
+ v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);
16272
+
16273
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
16274
+ WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable'
16275
+ AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;
16276
+ IF v_entry.id IS NOT NULL AND v_entry.status IN ('available', 'away') THEN
16277
+ SELECT count(*) + 1 INTO v_rank FROM public.plg_agenda_turn_entries o
16278
+ WHERE o.queue_id = v_queue.id AND o.area_key IS NOT DISTINCT FROM v_entry.area_key
16279
+ AND o.status IN ('available', 'away') AND o.id <> v_entry.id
16280
+ AND (o.position, o.joined_at, o.id) < (v_entry.position, v_entry.joined_at, v_entry.id);
16281
+ END IF;
16282
+ RETURN jsonb_build_object(
16283
+ 'professionalId', v_person,
16284
+ 'selfCheckin', v_queue.self_checkin,
16285
+ 'requireLocation', v_queue.geofence_required,
16286
+ 'inQueue', v_entry.id IS NOT NULL,
16287
+ 'area', coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, v_person)),
16288
+ 'status', v_entry.status,
16289
+ 'awayReason', v_entry.away_reason,
16290
+ 'rank', v_rank);
16291
+ END $$;
16292
+
16293
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_action(
16294
+ p_unit_id uuid,
16295
+ p_action text,
16296
+ p_latitude double precision DEFAULT NULL,
16297
+ p_longitude double precision DEFAULT NULL,
16298
+ p_accuracy_m double precision DEFAULT NULL,
16299
+ p_reason text DEFAULT NULL
16300
+ ) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
16301
+ DECLARE
16302
+ v_tenant uuid := app.current_tenant_id();
16303
+ v_person uuid;
16304
+ v_queue public.plg_agenda_turn_queues;
16305
+ v_entry public.plg_agenda_turn_entries;
16306
+ v_reason text := nullif(btrim(coalesce(p_reason, '')), '');
16307
+ v_lat numeric;
16308
+ v_lng numeric;
16309
+ v_distance double precision;
16310
+ v_area text;
16311
+ v_pos bigint;
16312
+ BEGIN
16313
+ IF v_tenant IS NULL OR auth.uid() IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16314
+ IF p_action NOT IN ('join', 'leave', 'away', 'back', 'finish') THEN
16315
+ RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';
16316
+ END IF;
16317
+ SELECT m.person_id INTO v_person FROM app.memberships m
16318
+ WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;
16319
+ IF v_person IS NULL THEN RAISE EXCEPTION 'turn_queue_professional_not_linked' USING ERRCODE = '42501'; END IF;
16320
+ IF NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN
16321
+ RAISE EXCEPTION 'turn_queue_professional_outside_unit' USING ERRCODE = '42501';
16322
+ END IF;
16323
+ SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q
16324
+ WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;
16325
+ IF v_queue.id IS NULL OR NOT v_queue.is_active THEN RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023'; END IF;
16326
+ IF NOT v_queue.self_checkin THEN RAISE EXCEPTION 'turn_queue_checkin_by_frontdesk' USING ERRCODE = '22023'; END IF;
16327
+ PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));
16328
+
16329
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
16330
+ WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable';
16331
+
16332
+ IF p_action = 'join' THEN
16333
+ IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16334
+ IF v_queue.geofence_required THEN
16335
+ SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;
16336
+ IF v_lat IS NULL OR v_lng IS NULL THEN RAISE EXCEPTION 'unit_has_no_coordinates' USING ERRCODE = '22023'; END IF;
16337
+ IF p_latitude IS NULL OR p_longitude IS NULL THEN RAISE EXCEPTION 'location_required' USING ERRCODE = '22023'; END IF;
16338
+ v_distance := 6371000 * acos(least(1, greatest(-1,
16339
+ cos(radians(v_lat::double precision)) * cos(radians(p_latitude)) * cos(radians(p_longitude) - radians(v_lng::double precision))
16340
+ + sin(radians(v_lat::double precision)) * sin(radians(p_latitude)))));
16341
+ IF v_distance > v_queue.geofence_radius_meters + least(coalesce(p_accuracy_m, 0), 100) THEN
16342
+ RAISE EXCEPTION 'outside_geofence' USING ERRCODE = '42501';
16343
+ END IF;
16344
+ END IF;
16345
+ v_area := public.agenda_turn__area_of(v_tenant, v_person);
16346
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_area);
16347
+ INSERT INTO public.plg_agenda_turn_entries
16348
+ (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, updated_at)
16349
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'available', v_pos, v_area, now(), now(), now(), now())
16350
+ ON CONFLICT (queue_id, professional_id) DO UPDATE SET
16351
+ status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),
16352
+ state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();
16353
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key,
16354
+ inside_geofence, location_accuracy_meters, payload)
16355
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'joined', 'app', auth.uid(), v_area,
16356
+ CASE WHEN v_queue.geofence_required THEN true END, p_accuracy_m, jsonb_build_object('to_position', v_pos));
16357
+ RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area);
16358
+ END IF;
16359
+
16360
+ IF v_entry.id IS NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16361
+
16362
+ IF p_action = 'leave' THEN
16363
+ UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,
16364
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16365
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
16366
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)
16367
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'left', 'app', auth.uid(), v_entry.area_key);
16368
+ ELSIF p_action = 'away' THEN
16369
+ IF v_entry.status <> 'available' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16370
+ UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),
16371
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16372
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason)
16373
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'away', 'app', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'));
16374
+ ELSIF p_action = 'back' THEN
16375
+ IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16376
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,
16377
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16378
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)
16379
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'back', 'app', auth.uid(), v_entry.area_key);
16380
+ ELSIF p_action = 'finish' THEN
16381
+ IF v_entry.status <> 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
16382
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);
16383
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,
16384
+ available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16385
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
16386
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'finished', 'app', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));
16387
+ END IF;
16388
+ RETURN jsonb_build_object('ok', true, 'changed', true);
16389
+ END $$;
16390
+
16391
+ -- 7) Configurar ---------------------------------------------------------------------
16392
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_config(p_unit_id uuid)
16393
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
16394
+ DECLARE
16395
+ v_tenant uuid := app.current_tenant_id();
16396
+ v_queue public.plg_agenda_turn_queues;
16397
+ BEGIN
16398
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16399
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN
16400
+ RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';
16401
+ END IF;
16402
+ v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);
16403
+ RETURN jsonb_build_object(
16404
+ 'unit', jsonb_build_object('auto', coalesce(v_queue.follows_appointments, true),
16405
+ 'selfCheckin', coalesce(v_queue.self_checkin, true),
16406
+ 'requireLocation', coalesce(v_queue.geofence_required, false)),
16407
+ 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color, 'keywords', to_jsonb(d.keywords)) ORDER BY d.sort_order, d.key)
16408
+ FROM public.agenda_turn__area_defs(v_tenant) d WHERE d.key <> 'geral'), '[]'::jsonb),
16409
+ 'professionals', coalesce((
16410
+ SELECT jsonb_agg(jsonb_build_object(
16411
+ 'professionalId', p.id, 'name', p.name,
16412
+ 'manualArea', pa.area_key,
16413
+ 'autoArea', public.agenda_turn__auto_area_of(v_tenant, p.id)
16414
+ ) ORDER BY p.name)
16415
+ FROM public.people p
16416
+ LEFT JOIN public.plg_agenda_turn_professional_areas pa ON pa.tenant_id = v_tenant AND pa.professional_id = p.id
16417
+ WHERE p.tenant_id = v_tenant AND p.is_active AND public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)), '[]'::jsonb),
16418
+ 'quickServiceIds', coalesce((SELECT jsonb_agg(q.product_id) FROM public.plg_agenda_turn_quick_services q WHERE q.tenant_id = v_tenant), '[]'::jsonb),
16419
+ 'services', coalesce((SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY s.name)
16420
+ FROM public.products s
16421
+ WHERE s.tenant_id = v_tenant AND s.kind = 'service' AND coalesce(s.is_active, true)), '[]'::jsonb)
16422
+ );
16423
+ END $$;
16424
+
16425
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_save_config(p_unit_id uuid, p_config jsonb)
16426
+ RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
16427
+ DECLARE
16428
+ v_tenant uuid := app.current_tenant_id();
16429
+ v_area jsonb;
16430
+ v_i int := 0;
16431
+ BEGIN
16432
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16433
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.edit', p_unit_id) THEN
16434
+ RAISE EXCEPTION 'agenda.turn_queue.edit is required' USING ERRCODE = '42501';
16435
+ END IF;
16436
+
16437
+ IF jsonb_typeof(p_config->'unit') = 'object' THEN
16438
+ INSERT INTO public.plg_agenda_turn_queues AS q (tenant_id, unit_id, is_active, follows_appointments, self_checkin, geofence_required)
16439
+ VALUES (v_tenant, p_unit_id, false,
16440
+ coalesce((p_config->'unit'->>'auto')::boolean, true),
16441
+ coalesce((p_config->'unit'->>'selfCheckin')::boolean, true),
16442
+ coalesce((p_config->'unit'->>'requireLocation')::boolean, false))
16443
+ ON CONFLICT (tenant_id, unit_id, queue_key) DO UPDATE SET
16444
+ follows_appointments = coalesce((p_config->'unit'->>'auto')::boolean, q.follows_appointments),
16445
+ self_checkin = coalesce((p_config->'unit'->>'selfCheckin')::boolean, q.self_checkin),
16446
+ geofence_required = coalesce((p_config->'unit'->>'requireLocation')::boolean, q.geofence_required),
16447
+ updated_at = now();
16448
+ END IF;
16449
+
16450
+ -- The list sent replaces the network's list; a missing area is deactivated.
16451
+ IF jsonb_typeof(p_config->'areas') = 'array' THEN
16452
+ UPDATE public.plg_agenda_turn_areas SET is_active = false, updated_at = now()
16453
+ WHERE tenant_id = v_tenant AND key NOT IN (SELECT x->>'key' FROM jsonb_array_elements(p_config->'areas') x);
16454
+ FOR v_area IN SELECT * FROM jsonb_array_elements(p_config->'areas') LOOP
16455
+ v_i := v_i + 1;
16456
+ INSERT INTO public.plg_agenda_turn_areas (tenant_id, key, name, color, keywords, sort_order, is_active)
16457
+ VALUES (v_tenant, v_area->>'key', btrim(v_area->>'name'), coalesce(v_area->>'color', 'slate'),
16458
+ coalesce(ARRAY(SELECT btrim(k) FROM jsonb_array_elements_text(coalesce(v_area->'keywords', '[]'::jsonb)) k WHERE btrim(k) <> ''), ARRAY[]::text[]),
16459
+ v_i, true)
16460
+ ON CONFLICT (tenant_id, key) DO UPDATE SET
16461
+ name = excluded.name, color = excluded.color, keywords = excluded.keywords,
16462
+ sort_order = excluded.sort_order, is_active = true, updated_at = now();
16463
+ END LOOP;
16464
+ END IF;
16465
+
16466
+ -- {"<person uuid>": "<area key>" | null}; null = back to Autom\xE1tico.
16467
+ IF jsonb_typeof(p_config->'professionalAreas') = 'object' THEN
16468
+ DELETE FROM public.plg_agenda_turn_professional_areas pa
16469
+ WHERE pa.tenant_id = v_tenant
16470
+ AND pa.professional_id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'));
16471
+ INSERT INTO public.plg_agenda_turn_professional_areas (tenant_id, professional_id, area_key)
16472
+ SELECT v_tenant, p.id, p_config->'professionalAreas'->>p.id::text
16473
+ FROM public.people p
16474
+ WHERE p.tenant_id = v_tenant
16475
+ AND p.id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'))
16476
+ AND nullif(p_config->'professionalAreas'->>p.id::text, '') IS NOT NULL;
16477
+ END IF;
16478
+
16479
+ IF jsonb_typeof(p_config->'quickServiceIds') = 'array' THEN
16480
+ DELETE FROM public.plg_agenda_turn_quick_services WHERE tenant_id = v_tenant;
16481
+ INSERT INTO public.plg_agenda_turn_quick_services (tenant_id, product_id)
16482
+ SELECT v_tenant, s.id FROM public.products s
16483
+ WHERE s.tenant_id = v_tenant
16484
+ AND s.id::text IN (SELECT x #>> '{}' FROM jsonb_array_elements(p_config->'quickServiceIds') x)
16485
+ ON CONFLICT DO NOTHING;
16486
+ END IF;
16487
+
16488
+ RETURN jsonb_build_object('ok', true);
16489
+ END $$;
16490
+
16491
+ -- 8) A fila anda sozinha pelo atendimento ---------------------------------------
16492
+ -- Replaces 016's trigger body: start leaves the queue unless every service is
16493
+ -- quick; leaving in_progress (any status) puts the person back at the END.
16494
+ -- Never blocks the agenda.
16495
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_on_appointment_status()
16496
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
16497
+ DECLARE
16498
+ v_unit uuid := coalesce(NEW.unit_id, NEW.location_id);
16499
+ v_queue public.plg_agenda_turn_queues;
16500
+ v_entry public.plg_agenda_turn_entries;
16501
+ v_tz text;
16502
+ v_pos bigint;
16503
+ v_quick boolean;
16504
+ BEGIN
16505
+ IF NEW.assignee_id IS NULL OR v_unit IS NULL OR NEW.status IS NOT DISTINCT FROM OLD.status THEN RETURN NEW; END IF;
16506
+ IF NEW.status <> 'in_progress' AND OLD.status IS DISTINCT FROM 'in_progress' THEN RETURN NEW; END IF;
16507
+
16508
+ SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q
16509
+ WHERE q.tenant_id = NEW.tenant_id AND q.unit_id = v_unit AND q.queue_key = 'walk_in'
16510
+ AND q.is_active AND q.follows_appointments
16511
+ FOR UPDATE;
16512
+ IF v_queue.id IS NULL THEN RETURN NEW; END IF;
16513
+ v_tz := public.agenda_turn__tz(NEW.tenant_id, v_unit);
16514
+
16515
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
16516
+ WHERE e.queue_id = v_queue.id AND e.professional_id = NEW.assignee_id AND e.status <> 'unavailable'
16517
+ AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;
16518
+ IF v_entry.id IS NULL THEN RETURN NEW; END IF;
16519
+
16520
+ IF NEW.status = 'in_progress' THEN
16521
+ IF v_entry.status = 'serving' THEN RETURN NEW; END IF;
16522
+ SELECT bool_and(q.product_id IS NOT NULL) INTO v_quick
16523
+ FROM public.appointment_items ai
16524
+ LEFT JOIN public.plg_agenda_turn_quick_services q ON q.tenant_id = NEW.tenant_id AND q.product_id = ai.service_id
16525
+ WHERE ai.booking_id = NEW.id;
16526
+ IF coalesce(v_quick, false) THEN
16527
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)
16528
+ VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'quick_service', 'automatic', NEW.id, v_entry.area_key);
16529
+ RETURN NEW;
16530
+ END IF;
16531
+ UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, appointment_id = NEW.id,
16532
+ previous_position = position, state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16533
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
16534
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)
16535
+ VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'started', 'automatic', NEW.id, v_entry.area_key);
16536
+ ELSIF v_entry.status = 'serving' AND v_entry.appointment_id = NEW.id THEN
16537
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);
16538
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,
16539
+ available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
16540
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key, payload)
16541
+ VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'finished', 'automatic', NEW.id, v_entry.area_key,
16542
+ jsonb_build_object('status', NEW.status, 'to_position', v_pos));
16543
+ END IF;
16544
+ RETURN NEW;
16545
+ EXCEPTION WHEN others THEN
16546
+ RAISE WARNING 'agenda_turn_queue_on_appointment_status: %', SQLERRM;
16547
+ RETURN NEW;
16548
+ END $$;
16549
+
16550
+ -- 9) Relat\xF3rio: a justi\xE7a da fila no per\xEDodo --------------------------------------
16551
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_report(p_unit_ids uuid[], p_from date, p_to date)
16552
+ RETURNS TABLE(unit_id uuid, unit_name text, professional_id uuid, professional_name text, area text,
16553
+ entradas bigint, atendimentos bigint, servicos_rapidos bigint, chamados bigint,
16554
+ pulos bigint, ausencias bigint, minutos_ausente numeric)
16555
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
16556
+ DECLARE
16557
+ v_tenant uuid := app.current_tenant_id();
16558
+ BEGIN
16559
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16560
+ RETURN QUERY
16561
+ WITH units_ok AS MATERIALIZED (
16562
+ SELECT u.id, u.name FROM public.v_units u
16563
+ WHERE u.tenant_id = v_tenant AND (p_unit_ids IS NULL OR u.id = ANY (p_unit_ids))
16564
+ AND app.has_unit(u.id) AND app.has_permission('agenda.turn_queue.read', u.id)
16565
+ ), ev AS MATERIALIZED (
16566
+ SELECT e.*, lead(e.occurred_at) OVER (PARTITION BY e.unit_id, e.professional_id ORDER BY e.occurred_at, e.id) AS proximo_em
16567
+ FROM public.plg_agenda_turn_events e JOIN units_ok u ON u.id = e.unit_id
16568
+ WHERE e.tenant_id = v_tenant
16569
+ AND (e.occurred_at AT TIME ZONE public.agenda_turn__tz(v_tenant, e.unit_id))::date BETWEEN p_from AND p_to
16570
+ )
16571
+ SELECT ev.unit_id, max(u.name), ev.professional_id, max(p.name), max(ev.area_key),
16572
+ count(*) FILTER (WHERE ev.event_type IN ('joined', 'available', 'override_available')),
16573
+ count(*) FILTER (WHERE ev.event_type IN ('started', 'advanced_to_service')),
16574
+ count(*) FILTER (WHERE ev.event_type = 'quick_service'),
16575
+ count(*) FILTER (WHERE ev.event_type = 'called'),
16576
+ count(*) FILTER (WHERE ev.event_type = 'skipped'),
16577
+ count(*) FILTER (WHERE ev.event_type = 'away'),
16578
+ round(coalesce(sum(extract(epoch FROM (ev.proximo_em - ev.occurred_at)) / 60)
16579
+ FILTER (WHERE ev.event_type = 'away' AND ev.proximo_em IS NOT NULL), 0)::numeric, 0)
16580
+ FROM ev
16581
+ JOIN units_ok u ON u.id = ev.unit_id
16582
+ JOIN public.people p ON p.id = ev.professional_id
16583
+ GROUP BY ev.unit_id, ev.professional_id
16584
+ ORDER BY 1, 7 DESC, 4;
16585
+ END $$;
16586
+
16587
+ -- 10) Grants e tempo real -----------------------------------------------------------
16588
+ REVOKE ALL ON FUNCTION public.agenda_turn__norm(text) FROM PUBLIC, anon;
16589
+ REVOKE ALL ON FUNCTION public.agenda_turn__area_defs(uuid) FROM PUBLIC, anon, authenticated;
16590
+ REVOKE ALL ON FUNCTION public.agenda_turn__auto_area_of(uuid, uuid) FROM PUBLIC, anon, authenticated;
16591
+ REVOKE ALL ON FUNCTION public.agenda_turn__area_of(uuid, uuid) FROM PUBLIC, anon, authenticated;
16592
+ REVOKE ALL ON FUNCTION public.agenda_turn__tz(uuid, uuid) FROM PUBLIC, anon, authenticated;
16593
+ REVOKE ALL ON FUNCTION public.agenda_turn__purge_stale(uuid, text) FROM PUBLIC, anon, authenticated;
16594
+ REVOKE ALL ON FUNCTION public.agenda_turn__renumber(uuid, text) FROM PUBLIC, anon, authenticated;
16595
+ REVOKE ALL ON FUNCTION public.agenda_turn__next_position(uuid, text) FROM PUBLIC, anon, authenticated;
16596
+ REVOKE ALL ON FUNCTION public.agenda_turn__in_unit(uuid, uuid, uuid) FROM PUBLIC, anon, authenticated;
16597
+ REVOKE ALL ON FUNCTION public.agenda_turn__queue(uuid, uuid) FROM PUBLIC, anon, authenticated;
16598
+ REVOKE ALL ON FUNCTION public.agenda_turn_queue_board(uuid) FROM PUBLIC, anon;
16599
+ REVOKE ALL ON FUNCTION public.agenda_turn_queue_act(uuid, uuid, text, integer, text) FROM PUBLIC, anon;
16600
+ REVOKE ALL ON FUNCTION public.agenda_turn_queue_my_state(uuid) FROM PUBLIC, anon;
16601
+ REVOKE ALL ON FUNCTION public.agenda_turn_queue_my_action(uuid, text, double precision, double precision, double precision, text) FROM PUBLIC, anon;
16602
+ REVOKE ALL ON FUNCTION public.agenda_turn_queue_config(uuid) FROM PUBLIC, anon;
16603
+ REVOKE ALL ON FUNCTION public.agenda_turn_queue_save_config(uuid, jsonb) FROM PUBLIC, anon;
16604
+ REVOKE ALL ON FUNCTION public.agenda_turn_queue_report(uuid[], date, date) FROM PUBLIC, anon;
16605
+ GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_board(uuid) TO authenticated;
16606
+ GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_act(uuid, uuid, text, integer, text) TO authenticated;
16607
+ GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_my_state(uuid) TO authenticated;
16608
+ GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_my_action(uuid, text, double precision, double precision, double precision, text) TO authenticated;
16609
+ GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_config(uuid) TO authenticated;
16610
+ GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_save_config(uuid, jsonb) TO authenticated;
16611
+ GRANT EXECUTE ON FUNCTION public.agenda_turn_queue_report(uuid[], date, date) TO authenticated;
16612
+
16613
+ DO $$
16614
+ BEGIN
16615
+ IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN
16616
+ IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'plg_agenda_turn_entries') THEN
16617
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_agenda_turn_entries;
16618
+ END IF;
16619
+ IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'plg_agenda_turn_events') THEN
16620
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_agenda_turn_events;
16621
+ END IF;
16622
+ END IF;
16623
+ END $$;
16624
+ `;
16625
+ var MIGRATION_022_A_CHAVE_DA_FILA_E_CONFIGURACAO_DA_UNIDADE = `-- A chave da fila \xE9 configura\xE7\xE3o da unidade, n\xE3o coluna.
16626
+ --
16627
+ -- 016/020/021 guardaram "ligada", "exigir loja", raio, "anda sozinha" e
16628
+ -- "check-in pelo profissional" como colunas de plg_agenda_turn_queues. O SDK j\xE1
16629
+ -- tem casa para isso (127/168): chave registrada em app.setting_keys com
16630
+ -- scope 'unit', valor em app.units.settings, lido em cascata
16631
+ -- unidade \u2192 casa \u2192 padr\xE3o. Um toggle novo vira uma linha de registro, n\xE3o uma
16632
+ -- migration de coluna, e a rede ganha "padr\xE3o da casa" sem 18 escritas.
16633
+ --
16634
+ -- plg_agenda_turn_queues continua: \xE9 a FILA (entidade, dona das entradas e da
16635
+ -- trilha). S\xF3 a configura\xE7\xE3o sai dela. As colunas antigas ficam paradas e s\xE3o
16636
+ -- removidas numa limpeza \xE0 parte (apps publicados ainda leem is_active).
16637
+ -- Idempotente: o espelho s\xF3 copia para unidade que ainda n\xE3o tem a chave.
16638
+
16639
+ SELECT app.seed_setting_keys($$[
16640
+ { "key": "agenda.turn_queue.enabled", "type": "boolean", "default": false, "scope": "unit", "reader": "plugin:agenda",
16641
+ "description": "Usar a lista da vez nesta unidade (V1: companies.turn_queue_enabled)." },
16642
+ { "key": "agenda.turn_queue.follows_appointments", "type": "boolean", "default": true, "scope": "unit", "reader": "plugin:agenda",
16643
+ "description": "A fila anda sozinha pelo atendimento: iniciar tira da vez, concluir devolve ao fim (V1: companies.turn_queue_auto)." },
16644
+ { "key": "agenda.turn_queue.self_checkin", "type": "boolean", "default": true, "scope": "unit", "reader": "plugin:agenda",
16645
+ "description": "O profissional faz o pr\xF3prio check-in pelo bot\xE3o Cheguei (V1: companies.turn_queue_self_checkin)." },
16646
+ { "key": "agenda.turn_queue.require_location", "type": "boolean", "default": false, "scope": "unit", "reader": "plugin:agenda",
16647
+ "description": "O Cheguei s\xF3 vale dentro do raio da loja (V1: companies.turn_queue_require_location)." },
16648
+ { "key": "agenda.turn_queue.radius_m", "type": "integer", "default": 80, "scope": "unit", "reader": "plugin:agenda",
16649
+ "description": "Raio da loja em metros, de 10 a 1000 (V1: companies.turn_queue_radius_m)." }
16650
+ ]$$::jsonb);
16651
+
16652
+ -- Leitura pela linha da fila: nula quando n\xE3o h\xE1 fila, para o chamador decidir.
16653
+ CREATE OR REPLACE FUNCTION public.agenda_turn__on(p_queue public.plg_agenda_turn_queues, p_key text)
16654
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
16655
+ SELECT CASE WHEN p_queue.id IS NULL THEN NULL
16656
+ ELSE (app.unit_setting_value(p_queue.tenant_id, p_queue.unit_id, 'agenda.turn_queue.' || p_key) #>> '{}')::boolean END;
16657
+ $$;
16658
+
16659
+ CREATE OR REPLACE FUNCTION public.agenda_turn__radius(p_queue public.plg_agenda_turn_queues)
16660
+ RETURNS integer LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
16661
+ SELECT coalesce((app.unit_setting_value(p_queue.tenant_id, p_queue.unit_id, 'agenda.turn_queue.radius_m') #>> '{}')::integer, 80);
16662
+ $$;
16663
+
16664
+ CREATE OR REPLACE FUNCTION public.agenda_turn__unit_cfg(p_tenant uuid, p_unit uuid)
16665
+ RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
16666
+ SELECT jsonb_build_object(
16667
+ 'enabled', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.enabled') #>> '{}')::boolean, false),
16668
+ 'auto', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.follows_appointments') #>> '{}')::boolean, true),
16669
+ 'selfCheckin', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.self_checkin') #>> '{}')::boolean, true),
16670
+ 'requireLocation', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.require_location') #>> '{}')::boolean, false),
16671
+ 'radiusM', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.radius_m') #>> '{}')::integer, 80));
16672
+ $$;
16673
+
16674
+ -- Grava s\xF3 a diferen\xE7a para o padr\xE3o da casa; igual ao padr\xE3o apaga a exce\xE7\xE3o.
16675
+ -- Compara pelo tenant da unidade, n\xE3o da sess\xE3o: dentro de migration n\xE3o h\xE1 sess\xE3o.
16676
+ CREATE OR REPLACE FUNCTION public.agenda_turn__put(p_unit uuid, p_key text, p_value jsonb)
16677
+ RETURNS void LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
16678
+ UPDATE app.units u
16679
+ SET settings = app.jsonb_set_path(coalesce(u.settings, '{}'::jsonb), string_to_array('agenda.turn_queue.' || p_key, '.'),
16680
+ CASE WHEN app.unit_setting_value(u.tenant_id, NULL, 'agenda.turn_queue.' || p_key) IS NOT DISTINCT FROM p_value THEN NULL ELSE p_value END)
16681
+ WHERE u.id = p_unit AND p_value IS NOT NULL AND p_value <> 'null'::jsonb;
16682
+ $$;
16683
+
16684
+ -- Espelho \xFAnico do que as colunas diziam.
16685
+ DO $$
16686
+ DECLARE r record;
16687
+ BEGIN
16688
+ FOR r IN
16689
+ SELECT q.* FROM public.plg_agenda_turn_queues q JOIN app.units u ON u.id = q.unit_id
16690
+ WHERE q.queue_key = 'walk_in' AND u.settings #> '{agenda,turn_queue}' IS NULL
16691
+ LOOP
16692
+ PERFORM public.agenda_turn__put(r.unit_id, 'enabled', to_jsonb(r.is_active));
16693
+ PERFORM public.agenda_turn__put(r.unit_id, 'follows_appointments', to_jsonb(r.follows_appointments));
16694
+ PERFORM public.agenda_turn__put(r.unit_id, 'self_checkin', to_jsonb(r.self_checkin));
16695
+ PERFORM public.agenda_turn__put(r.unit_id, 'require_location', to_jsonb(r.geofence_required));
16696
+ PERFORM public.agenda_turn__put(r.unit_id, 'radius_m', to_jsonb(r.geofence_radius_meters));
16697
+ END LOOP;
16698
+ -- Re-pass what is already stored so values equal to the house default stop
16699
+ -- being exceptions (a first replay wrote them all).
16700
+ FOR r IN SELECT u.id, k.key, k.value FROM app.units u, jsonb_each(u.settings #> '{agenda,turn_queue}') k
16701
+ WHERE jsonb_typeof(u.settings #> '{agenda,turn_queue}') = 'object' LOOP
16702
+ PERFORM public.agenda_turn__put(r.id, r.key, r.value);
16703
+ END LOOP;
16704
+ END $$;
16705
+
16706
+ COMMENT ON COLUMN public.plg_agenda_turn_queues.is_active IS 'Superada por app.units.settings agenda.turn_queue.enabled (022). N\xE3o ler.';
16707
+ COMMENT ON COLUMN public.plg_agenda_turn_queues.geofence_required IS 'Superada por agenda.turn_queue.require_location (022). N\xE3o ler.';
16708
+ COMMENT ON COLUMN public.plg_agenda_turn_queues.geofence_radius_meters IS 'Superada por agenda.turn_queue.radius_m (022). N\xE3o ler.';
16709
+ COMMENT ON COLUMN public.plg_agenda_turn_queues.follows_appointments IS 'Superada por agenda.turn_queue.follows_appointments (022). N\xE3o ler.';
16710
+ COMMENT ON COLUMN public.plg_agenda_turn_queues.self_checkin IS 'Superada por agenda.turn_queue.self_checkin (022). N\xE3o ler.';
16711
+
16712
+ -- A aba Lista da vez da unidade (020): mesma assinatura, agora escrevendo a configura\xE7\xE3o.
16713
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_configure(
16714
+ p_unit_id uuid,
16715
+ p_enabled boolean,
16716
+ p_geofence_required boolean DEFAULT NULL,
16717
+ p_radius_meters integer DEFAULT NULL
16718
+ )
16719
+ RETURNS public.plg_agenda_turn_queues
16720
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$
16721
+ DECLARE
16722
+ v_tenant uuid := app.current_tenant_id();
16723
+ v_row public.plg_agenda_turn_queues;
16724
+ v_cfg jsonb;
16725
+ BEGIN
16726
+ IF v_tenant IS NULL OR auth.uid() IS NULL THEN
16727
+ RAISE EXCEPTION 'agenda_turn_queue_configure: no session or no active tenant' USING ERRCODE = '42501';
16728
+ END IF;
16729
+ IF NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant) THEN
16730
+ RAISE EXCEPTION 'agenda_turn_queue_configure: unit does not belong to this tenant' USING ERRCODE = '42501';
16731
+ END IF;
16732
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('settings.manage', p_unit_id) THEN
16733
+ RAISE EXCEPTION 'agenda_turn_queue_configure: settings.manage is required on this unit' USING ERRCODE = '42501';
16734
+ END IF;
16735
+ IF p_radius_meters IS NOT NULL AND (p_radius_meters < 10 OR p_radius_meters > 1000) THEN
16736
+ RAISE EXCEPTION 'agenda_turn_queue_configure: radius must be between 10 and 1000 meters' USING ERRCODE = '22023';
16737
+ END IF;
16738
+
16739
+ INSERT INTO public.plg_agenda_turn_queues (tenant_id, unit_id) VALUES (v_tenant, p_unit_id)
16740
+ ON CONFLICT (tenant_id, unit_id, queue_key) DO NOTHING;
16741
+ PERFORM public.agenda_turn__put(p_unit_id, 'enabled', to_jsonb(p_enabled));
16742
+ PERFORM public.agenda_turn__put(p_unit_id, 'require_location', to_jsonb(p_geofence_required));
16743
+ PERFORM public.agenda_turn__put(p_unit_id, 'radius_m', to_jsonb(p_radius_meters));
16744
+
16745
+ -- The return row carries the EFFECTIVE values, so the caller reads the same
16746
+ -- fields it always did.
16747
+ SELECT * INTO v_row FROM public.plg_agenda_turn_queues q
16748
+ WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in';
16749
+ v_cfg := public.agenda_turn__unit_cfg(v_tenant, p_unit_id);
16750
+ v_row.is_active := (v_cfg->>'enabled')::boolean;
16751
+ v_row.geofence_required := (v_cfg->>'requireLocation')::boolean;
16752
+ v_row.geofence_radius_meters := (v_cfg->>'radiusM')::integer;
16753
+ v_row.follows_appointments := (v_cfg->>'auto')::boolean;
16754
+ v_row.self_checkin := (v_cfg->>'selfCheckin')::boolean;
16755
+ RETURN v_row;
16756
+ END $function$;
16757
+
16758
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_config(p_unit_id uuid)
16759
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
16760
+ DECLARE
16761
+ v_tenant uuid := app.current_tenant_id();
16762
+ BEGIN
16763
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16764
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN
16765
+ RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';
16766
+ END IF;
16767
+ RETURN jsonb_build_object(
16768
+ 'unit', public.agenda_turn__unit_cfg(v_tenant, p_unit_id),
16769
+ 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color, 'keywords', to_jsonb(d.keywords)) ORDER BY d.sort_order, d.key)
16770
+ FROM public.agenda_turn__area_defs(v_tenant) d WHERE d.key <> 'geral'), '[]'::jsonb),
16771
+ 'professionals', coalesce((
16772
+ SELECT jsonb_agg(jsonb_build_object(
16773
+ 'professionalId', p.id, 'name', p.name,
16774
+ 'manualArea', pa.area_key,
16775
+ 'autoArea', public.agenda_turn__auto_area_of(v_tenant, p.id)
16776
+ ) ORDER BY p.name)
16777
+ FROM public.people p
16778
+ LEFT JOIN public.plg_agenda_turn_professional_areas pa ON pa.tenant_id = v_tenant AND pa.professional_id = p.id
16779
+ WHERE p.tenant_id = v_tenant AND p.is_active AND public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)), '[]'::jsonb),
16780
+ 'quickServiceIds', coalesce((SELECT jsonb_agg(q.product_id) FROM public.plg_agenda_turn_quick_services q WHERE q.tenant_id = v_tenant), '[]'::jsonb),
16781
+ 'services', coalesce((SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY s.name)
16782
+ FROM public.products s
16783
+ WHERE s.tenant_id = v_tenant AND s.kind = 'service' AND coalesce(s.is_active, true)), '[]'::jsonb)
16784
+ );
16785
+ END $$;
16786
+
16787
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_save_config(p_unit_id uuid, p_config jsonb)
16788
+ RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
16789
+ DECLARE
16790
+ v_tenant uuid := app.current_tenant_id();
16791
+ v_area jsonb;
16792
+ v_i int := 0;
16793
+ BEGIN
16794
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
16795
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.edit', p_unit_id) THEN
16796
+ RAISE EXCEPTION 'agenda.turn_queue.edit is required' USING ERRCODE = '42501';
16797
+ END IF;
16798
+ IF NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant) THEN
16799
+ RAISE EXCEPTION 'unit does not belong to this tenant' USING ERRCODE = '42501';
16800
+ END IF;
16801
+
16802
+ IF jsonb_typeof(p_config->'unit') = 'object' THEN
16803
+ PERFORM public.agenda_turn__put(p_unit_id, 'follows_appointments', p_config->'unit'->'auto');
16804
+ PERFORM public.agenda_turn__put(p_unit_id, 'self_checkin', p_config->'unit'->'selfCheckin');
16805
+ PERFORM public.agenda_turn__put(p_unit_id, 'require_location', p_config->'unit'->'requireLocation');
16806
+ END IF;
16807
+
16808
+ -- The list sent replaces the network's list; a missing area is deactivated.
16809
+ IF jsonb_typeof(p_config->'areas') = 'array' THEN
16810
+ UPDATE public.plg_agenda_turn_areas SET is_active = false, updated_at = now()
16811
+ WHERE tenant_id = v_tenant AND key NOT IN (SELECT x->>'key' FROM jsonb_array_elements(p_config->'areas') x);
16812
+ FOR v_area IN SELECT * FROM jsonb_array_elements(p_config->'areas') LOOP
16813
+ v_i := v_i + 1;
16814
+ INSERT INTO public.plg_agenda_turn_areas (tenant_id, key, name, color, keywords, sort_order, is_active)
16815
+ VALUES (v_tenant, v_area->>'key', btrim(v_area->>'name'), coalesce(v_area->>'color', 'slate'),
16816
+ coalesce(ARRAY(SELECT btrim(k) FROM jsonb_array_elements_text(coalesce(v_area->'keywords', '[]'::jsonb)) k WHERE btrim(k) <> ''), ARRAY[]::text[]),
16817
+ v_i, true)
16818
+ ON CONFLICT (tenant_id, key) DO UPDATE SET
16819
+ name = excluded.name, color = excluded.color, keywords = excluded.keywords,
16820
+ sort_order = excluded.sort_order, is_active = true, updated_at = now();
16821
+ END LOOP;
16822
+ END IF;
16823
+
16824
+ IF jsonb_typeof(p_config->'professionalAreas') = 'object' THEN
16825
+ DELETE FROM public.plg_agenda_turn_professional_areas pa
16826
+ WHERE pa.tenant_id = v_tenant
16827
+ AND pa.professional_id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'));
16828
+ INSERT INTO public.plg_agenda_turn_professional_areas (tenant_id, professional_id, area_key)
16829
+ SELECT v_tenant, p.id, p_config->'professionalAreas'->>p.id::text
16830
+ FROM public.people p
16831
+ WHERE p.tenant_id = v_tenant
16832
+ AND p.id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'))
16833
+ AND nullif(p_config->'professionalAreas'->>p.id::text, '') IS NOT NULL;
16834
+ END IF;
16835
+
16836
+ IF jsonb_typeof(p_config->'quickServiceIds') = 'array' THEN
16837
+ DELETE FROM public.plg_agenda_turn_quick_services WHERE tenant_id = v_tenant;
16838
+ INSERT INTO public.plg_agenda_turn_quick_services (tenant_id, product_id)
16839
+ SELECT v_tenant, s.id FROM public.products s
16840
+ WHERE s.tenant_id = v_tenant
16841
+ AND s.id::text IN (SELECT x #>> '{}' FROM jsonb_array_elements(p_config->'quickServiceIds') x)
16842
+ ON CONFLICT DO NOTHING;
16843
+ END IF;
16844
+
16845
+ RETURN jsonb_build_object('ok', true);
16846
+ END $$;
16847
+
16848
+ -- Fun\xE7\xF5es de 016/017/021 com a mesma l\xF3gica, lendo a configura\xE7\xE3o pela cascata.
16849
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_set_availability(
16850
+ p_available boolean,
16851
+ p_unit_id uuid,
16852
+ p_latitude numeric DEFAULT NULL,
16853
+ p_longitude numeric DEFAULT NULL,
16854
+ p_accuracy_meters numeric DEFAULT NULL,
16855
+ p_origin text DEFAULT 'app'
16856
+ ) RETURNS jsonb
16857
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
16858
+ DECLARE
16859
+ v_tenant uuid := app.current_tenant_id();
16860
+ v_actor uuid := auth.uid();
16861
+ v_person uuid;
16862
+ v_queue public.plg_agenda_turn_queues%ROWTYPE;
16863
+ v_lat numeric;
16864
+ v_lng numeric;
16865
+ v_distance numeric;
16866
+ v_position bigint;
16867
+ v_entry uuid;
16868
+ BEGIN
16869
+ IF v_tenant IS NULL THEN
16870
+ RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501';
16871
+ END IF;
16872
+ IF p_unit_id IS NULL THEN
16873
+ RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023';
16874
+ END IF;
16875
+ IF p_origin <> 'app' THEN
16876
+ RAISE EXCEPTION 'self_availability_origin_must_be_app' USING ERRCODE = '22023';
16877
+ END IF;
16878
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.edit', p_unit_id) THEN
16879
+ RAISE EXCEPTION 'agenda.turn_queue.edit is required' USING ERRCODE = '42501';
16880
+ END IF;
16881
+
16882
+ v_person := app.person_for_login(v_tenant, v_actor);
16883
+ IF v_person IS NULL THEN
16884
+ RAISE EXCEPTION 'sem_ficha_de_profissional' USING ERRCODE = '42501';
16885
+ END IF;
16886
+
16887
+ INSERT INTO public.plg_agenda_turn_queues (tenant_id, unit_id)
16888
+ VALUES (v_tenant, p_unit_id)
16889
+ ON CONFLICT (tenant_id, unit_id, queue_key) DO NOTHING;
16890
+
16891
+ SELECT * INTO v_queue
16892
+ FROM public.plg_agenda_turn_queues q
16893
+ WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id
16894
+ AND q.queue_key = 'walk_in' AND public.agenda_turn__on(q, 'enabled')
16895
+ FOR UPDATE;
16896
+ IF v_queue.id IS NULL THEN
16897
+ RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '42501';
16898
+ END IF;
16899
+
16900
+ IF p_available AND public.agenda_turn__on(v_queue, 'require_location') THEN
16901
+ SELECT u.latitude, u.longitude INTO v_lat, v_lng
16902
+ FROM public.v_units u
16903
+ WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;
16904
+ IF v_lat IS NULL OR v_lng IS NULL THEN
16905
+ RAISE EXCEPTION 'unit_has_no_coordinates' USING ERRCODE = '22023';
16906
+ END IF;
16907
+ IF p_latitude IS NULL OR p_longitude IS NULL THEN
16908
+ RAISE EXCEPTION 'location_required' USING ERRCODE = '22023';
16909
+ END IF;
16910
+ v_distance := 6371000 * acos(least(1, greatest(-1,
16911
+ cos(radians(v_lat::double precision)) * cos(radians(p_latitude::double precision))
16912
+ * cos(radians(p_longitude::double precision) - radians(v_lng::double precision))
16913
+ + sin(radians(v_lat::double precision)) * sin(radians(p_latitude::double precision))
16914
+ )));
16915
+ IF v_distance > public.agenda_turn__radius(v_queue) THEN
16916
+ RAISE EXCEPTION 'outside_geofence' USING ERRCODE = '42501';
16917
+ END IF;
16918
+ END IF;
16919
+
16920
+ SELECT coalesce(max(e.position), 0) + 1 INTO v_position
16921
+ FROM public.plg_agenda_turn_entries e WHERE e.queue_id = v_queue.id;
16922
+
16923
+ INSERT INTO public.plg_agenda_turn_entries
16924
+ (tenant_id, queue_id, unit_id, professional_id, status, position, available_at, state_changed_at, appointment_id, updated_at)
16925
+ VALUES
16926
+ (v_tenant, v_queue.id, p_unit_id, v_person,
16927
+ CASE WHEN p_available THEN 'available' ELSE 'unavailable' END,
16928
+ CASE WHEN p_available THEN v_position ELSE 0 END,
16929
+ CASE WHEN p_available THEN now() ELSE NULL END,
16930
+ now(), NULL, now())
16931
+ ON CONFLICT (queue_id, professional_id) DO UPDATE SET
16932
+ status = EXCLUDED.status,
16933
+ position = CASE WHEN p_available THEN v_position ELSE public.plg_agenda_turn_entries.position END,
16934
+ available_at = EXCLUDED.available_at,
16935
+ state_changed_at = now(),
16936
+ appointment_id = NULL,
16937
+ updated_at = now()
16938
+ RETURNING id INTO v_entry;
16939
+
16940
+ INSERT INTO public.plg_agenda_turn_events
16941
+ (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id,
16942
+ inside_geofence, location_accuracy_meters, payload)
16943
+ VALUES
16944
+ (v_tenant, v_queue.id, p_unit_id, v_person,
16945
+ CASE WHEN p_available THEN 'available' ELSE 'left' END,
16946
+ 'app', v_actor, CASE WHEN p_available THEN true ELSE NULL END,
16947
+ CASE WHEN p_available THEN p_accuracy_meters ELSE NULL END,
16948
+ jsonb_build_object('entry_id', v_entry));
16949
+
16950
+ RETURN jsonb_build_object('ok', true, 'status', CASE WHEN p_available THEN 'available' ELSE 'unavailable' END);
16951
+ END $$;
16952
+
16953
+
16954
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_snapshot(p_unit_id uuid)
16955
+ RETURNS jsonb
16956
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' STABLE AS $$
16957
+ DECLARE
16958
+ v_tenant uuid := app.current_tenant_id();
16959
+ v_queue public.plg_agenda_turn_queues%ROWTYPE;
16960
+ v_person uuid;
16961
+ v_timezone text;
16962
+ v_day date;
16963
+ v_entries jsonb;
16964
+ v_events jsonb;
16965
+ v_professionals jsonb;
16966
+ BEGIN
16967
+ IF p_unit_id IS NULL THEN
16968
+ RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023';
16969
+ END IF;
16970
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN
16971
+ RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';
16972
+ END IF;
16973
+
16974
+ SELECT coalesce(t.settings->>'timezone', 'America/Sao_Paulo')
16975
+ INTO v_timezone
16976
+ FROM public.tenants t
16977
+ WHERE t.id = v_tenant;
16978
+ v_day := (now() AT TIME ZONE v_timezone)::date;
16979
+ v_person := app.person_for_login(v_tenant, auth.uid());
16980
+
16981
+ SELECT * INTO v_queue
16982
+ FROM public.plg_agenda_turn_queues q
16983
+ WHERE q.tenant_id = v_tenant
16984
+ AND q.unit_id = p_unit_id
16985
+ AND q.queue_key = 'walk_in'
16986
+ AND public.agenda_turn__on(q, 'enabled');
16987
+
16988
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
16989
+ 'id', e.id, 'queue_id', e.queue_id, 'unit_id', e.unit_id,
16990
+ 'professional_id', e.professional_id, 'professional_name', p.name,
16991
+ 'professional_avatar_url', p.avatar_url, 'status', e.status, 'position', e.position,
16992
+ 'state_changed_at', e.state_changed_at, 'available_at', e.available_at,
16993
+ 'appointment_id', e.appointment_id
16994
+ ) ORDER BY CASE e.status WHEN 'available' THEN 0 WHEN 'serving' THEN 1 ELSE 2 END, e.position), '[]'::jsonb)
16995
+ INTO v_entries
16996
+ FROM public.plg_agenda_turn_entries e
16997
+ JOIN public.people p ON p.id = e.professional_id AND p.tenant_id = e.tenant_id
16998
+ WHERE e.queue_id = v_queue.id;
16999
+
17000
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
17001
+ 'id', ev.id, 'professional_id', ev.professional_id, 'professional_name', p.name,
17002
+ 'event_type', ev.event_type, 'origin', ev.origin, 'reason', ev.reason,
17003
+ 'occurred_at', ev.occurred_at
17004
+ ) ORDER BY ev.occurred_at DESC), '[]'::jsonb)
17005
+ INTO v_events
17006
+ FROM (
17007
+ SELECT * FROM public.plg_agenda_turn_events
17008
+ WHERE queue_id = v_queue.id
17009
+ AND (occurred_at AT TIME ZONE v_timezone)::date = v_day
17010
+ ORDER BY occurred_at DESC LIMIT 50
17011
+ ) ev
17012
+ JOIN public.people p ON p.id = ev.professional_id AND p.tenant_id = ev.tenant_id;
17013
+
17014
+ WITH scheduled AS (
17015
+ SELECT DISTINCT s.assignee_id AS professional_id
17016
+ FROM public.schedules s
17017
+ WHERE s.tenant_id = v_tenant
17018
+ AND s.kind = 'working_hours'
17019
+ AND s.assignee_id IS NOT NULL
17020
+ AND coalesce(s.unit_id, s.location_id) = p_unit_id
17021
+ AND (s.valid_from IS NULL OR s.valid_from <= v_day)
17022
+ AND (s.valid_to IS NULL OR v_day <= s.valid_to)
17023
+ AND (
17024
+ (s.specific_date = v_day AND s.is_active)
17025
+ OR (
17026
+ s.specific_date IS NULL
17027
+ AND s.is_active
17028
+ AND s.day_of_week = extract(dow FROM v_day)::smallint
17029
+ AND public.agenda_week_parity_active(s.valid_from, v_day, s.week_interval)
17030
+ AND NOT EXISTS (
17031
+ SELECT 1 FROM public.schedules day_override
17032
+ WHERE day_override.tenant_id = s.tenant_id
17033
+ AND day_override.assignee_id = s.assignee_id
17034
+ AND coalesce(day_override.unit_id, day_override.location_id) = p_unit_id
17035
+ AND day_override.specific_date = v_day
17036
+ )
17037
+ )
17038
+ )
17039
+ ), booked AS (
17040
+ SELECT a.assignee_id AS professional_id,
17041
+ count(*)::integer AS appointment_count,
17042
+ min(a.starts_at) FILTER (WHERE a.starts_at >= now()) AS next_appointment_at
17043
+ FROM public.appointments a
17044
+ WHERE a.tenant_id = v_tenant
17045
+ AND a.assignee_id IS NOT NULL
17046
+ AND coalesce(a.unit_id, a.location_id) = p_unit_id
17047
+ AND (a.starts_at AT TIME ZONE v_timezone)::date = v_day
17048
+ AND coalesce(a.status, '') NOT IN ('cancelled', 'canceled')
17049
+ GROUP BY a.assignee_id
17050
+ ), candidates AS (
17051
+ SELECT professional_id FROM scheduled
17052
+ UNION
17053
+ SELECT professional_id FROM booked
17054
+ UNION
17055
+ SELECT e.professional_id FROM public.plg_agenda_turn_entries e WHERE e.queue_id = v_queue.id
17056
+ )
17057
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
17058
+ 'professional_id', p.id,
17059
+ 'professional_name', p.name,
17060
+ 'professional_avatar_url', p.avatar_url,
17061
+ 'status', coalesce(e.status, 'unavailable'),
17062
+ 'position', coalesce(e.position, 0),
17063
+ 'last_checkin_at', last_checkin.occurred_at,
17064
+ 'is_scheduled', scheduled.professional_id IS NOT NULL,
17065
+ 'appointment_count', coalesce(booked.appointment_count, 0),
17066
+ 'next_appointment_at', booked.next_appointment_at
17067
+ ) ORDER BY
17068
+ CASE coalesce(e.status, 'unavailable') WHEN 'available' THEN 0 WHEN 'serving' THEN 1 ELSE 2 END,
17069
+ CASE WHEN booked.next_appointment_at IS NOT NULL THEN 0 ELSE 1 END,
17070
+ e.position NULLS LAST,
17071
+ p.name), '[]'::jsonb)
17072
+ INTO v_professionals
17073
+ FROM candidates c
17074
+ JOIN public.people p ON p.id = c.professional_id AND p.tenant_id = v_tenant AND p.is_active
17075
+ LEFT JOIN scheduled ON scheduled.professional_id = p.id
17076
+ LEFT JOIN booked ON booked.professional_id = p.id
17077
+ LEFT JOIN public.plg_agenda_turn_entries e ON e.queue_id = v_queue.id AND e.professional_id = p.id
17078
+ LEFT JOIN LATERAL (
17079
+ SELECT ev.occurred_at
17080
+ FROM public.plg_agenda_turn_events ev
17081
+ WHERE ev.queue_id = v_queue.id
17082
+ AND ev.professional_id = p.id
17083
+ AND ev.event_type IN ('available', 'override_available')
17084
+ AND (ev.occurred_at AT TIME ZONE v_timezone)::date = v_day
17085
+ ORDER BY ev.occurred_at DESC
17086
+ LIMIT 1
17087
+ ) last_checkin ON true;
17088
+
17089
+ RETURN jsonb_build_object(
17090
+ 'queue_id', v_queue.id,
17091
+ 'unit_id', p_unit_id,
17092
+ 'queue_name', coalesce(v_queue.name, 'Pr\xF3ximo da vez'),
17093
+ 'current_professional_id', v_person,
17094
+ 'date', v_day,
17095
+ 'professionals', v_professionals,
17096
+ 'entries', v_entries,
17097
+ 'events', v_events
17098
+ );
17099
+ END $$;
17100
+
17101
+
17102
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_override(
17103
+ p_professional_id uuid,
17104
+ p_unit_id uuid,
17105
+ p_available boolean,
17106
+ p_reason text
17107
+ ) RETURNS jsonb
17108
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
17109
+ DECLARE
17110
+ v_tenant uuid := app.current_tenant_id();
17111
+ v_queue public.plg_agenda_turn_queues%ROWTYPE;
17112
+ v_position bigint;
17113
+ BEGIN
17114
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.manage', p_unit_id) THEN
17115
+ RAISE EXCEPTION 'agenda.turn_queue.manage is required' USING ERRCODE = '42501';
17116
+ END IF;
17117
+ IF nullif(trim(p_reason), '') IS NULL THEN
17118
+ RAISE EXCEPTION 'override_reason_required' USING ERRCODE = '22023';
17119
+ END IF;
17120
+ SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q
17121
+ WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' AND public.agenda_turn__on(q, 'enabled')
17122
+ FOR UPDATE;
17123
+ IF v_queue.id IS NULL THEN RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '42501'; END IF;
17124
+ SELECT coalesce(max(position), 0) + 1 INTO v_position FROM public.plg_agenda_turn_entries WHERE queue_id = v_queue.id;
17125
+ INSERT INTO public.plg_agenda_turn_entries
17126
+ (tenant_id, queue_id, unit_id, professional_id, status, position, available_at, state_changed_at, updated_at)
17127
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id,
17128
+ CASE WHEN p_available THEN 'available' ELSE 'unavailable' END,
17129
+ CASE WHEN p_available THEN v_position ELSE 0 END,
17130
+ CASE WHEN p_available THEN now() ELSE NULL END, now(), now())
17131
+ ON CONFLICT (queue_id, professional_id) DO UPDATE SET
17132
+ status = EXCLUDED.status, position = CASE WHEN p_available THEN v_position ELSE public.plg_agenda_turn_entries.position END,
17133
+ available_at = EXCLUDED.available_at, state_changed_at = now(), appointment_id = NULL, updated_at = now();
17134
+ INSERT INTO public.plg_agenda_turn_events
17135
+ (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, reason)
17136
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id,
17137
+ CASE WHEN p_available THEN 'override_available' ELSE 'override_left' END,
17138
+ 'frontdesk', auth.uid(), trim(p_reason));
17139
+ RETURN jsonb_build_object('ok', true);
17140
+ END $$;
17141
+
17142
+
17143
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_on_appointment_status()
17144
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
17145
+ DECLARE
17146
+ v_unit uuid := coalesce(NEW.unit_id, NEW.location_id);
17147
+ v_queue public.plg_agenda_turn_queues;
17148
+ v_entry public.plg_agenda_turn_entries;
17149
+ v_tz text;
17150
+ v_pos bigint;
17151
+ v_quick boolean;
17152
+ BEGIN
17153
+ IF NEW.assignee_id IS NULL OR v_unit IS NULL OR NEW.status IS NOT DISTINCT FROM OLD.status THEN RETURN NEW; END IF;
17154
+ IF NEW.status <> 'in_progress' AND OLD.status IS DISTINCT FROM 'in_progress' THEN RETURN NEW; END IF;
17155
+
17156
+ SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q
17157
+ WHERE q.tenant_id = NEW.tenant_id AND q.unit_id = v_unit AND q.queue_key = 'walk_in'
17158
+ AND public.agenda_turn__on(q, 'enabled') AND public.agenda_turn__on(q, 'follows_appointments')
17159
+ FOR UPDATE;
17160
+ IF v_queue.id IS NULL THEN RETURN NEW; END IF;
17161
+ v_tz := public.agenda_turn__tz(NEW.tenant_id, v_unit);
17162
+
17163
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
17164
+ WHERE e.queue_id = v_queue.id AND e.professional_id = NEW.assignee_id AND e.status <> 'unavailable'
17165
+ AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;
17166
+ IF v_entry.id IS NULL THEN RETURN NEW; END IF;
17167
+
17168
+ IF NEW.status = 'in_progress' THEN
17169
+ IF v_entry.status = 'serving' THEN RETURN NEW; END IF;
17170
+ SELECT bool_and(q.product_id IS NOT NULL) INTO v_quick
17171
+ FROM public.appointment_items ai
17172
+ LEFT JOIN public.plg_agenda_turn_quick_services q ON q.tenant_id = NEW.tenant_id AND q.product_id = ai.service_id
17173
+ WHERE ai.booking_id = NEW.id;
17174
+ IF coalesce(v_quick, false) THEN
17175
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)
17176
+ VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'quick_service', 'automatic', NEW.id, v_entry.area_key);
17177
+ RETURN NEW;
17178
+ END IF;
17179
+ UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, appointment_id = NEW.id,
17180
+ previous_position = position, state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17181
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
17182
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)
17183
+ VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'started', 'automatic', NEW.id, v_entry.area_key);
17184
+ ELSIF v_entry.status = 'serving' AND v_entry.appointment_id = NEW.id THEN
17185
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);
17186
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,
17187
+ available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17188
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key, payload)
17189
+ VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'finished', 'automatic', NEW.id, v_entry.area_key,
17190
+ jsonb_build_object('status', NEW.status, 'to_position', v_pos));
17191
+ END IF;
17192
+ RETURN NEW;
17193
+ EXCEPTION WHEN others THEN
17194
+ RAISE WARNING 'agenda_turn_queue_on_appointment_status: %', SQLERRM;
17195
+ RETURN NEW;
17196
+ END $$;
17197
+
17198
+
17199
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_board(p_unit_id uuid)
17200
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
17201
+ DECLARE
17202
+ v_tenant uuid := app.current_tenant_id();
17203
+ v_queue public.plg_agenda_turn_queues;
17204
+ v_tz text;
17205
+ v_day date;
17206
+ v_lat numeric;
17207
+ v_lng numeric;
17208
+ v_result jsonb;
17209
+ BEGIN
17210
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
17211
+ IF p_unit_id IS NULL THEN RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023'; END IF;
17212
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN
17213
+ RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';
17214
+ END IF;
17215
+ v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);
17216
+ v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);
17217
+ v_day := (now() AT TIME ZONE v_tz)::date;
17218
+ SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;
17219
+
17220
+ WITH ent AS (
17221
+ SELECT e.*, row_number() OVER (PARTITION BY e.area_key, (e.status IN ('available', 'away'))
17222
+ ORDER BY e.position, e.joined_at, e.id) AS rnk
17223
+ FROM public.plg_agenda_turn_entries e
17224
+ WHERE e.queue_id = v_queue.id AND e.status IN ('available', 'away', 'serving')
17225
+ AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = v_day
17226
+ ), appts AS (
17227
+ SELECT a.assignee_id AS professional_id, count(*)::int AS n,
17228
+ min(a.starts_at) FILTER (WHERE a.starts_at >= now() AND a.status NOT IN ('completed', 'cancelled', 'no_show')) AS proximo
17229
+ FROM public.appointments a
17230
+ WHERE a.tenant_id = v_tenant AND a.assignee_id IS NOT NULL
17231
+ AND coalesce(a.unit_id, a.location_id) = p_unit_id
17232
+ AND (a.starts_at AT TIME ZONE v_tz)::date = v_day
17233
+ AND a.status <> 'cancelled'
17234
+ GROUP BY 1
17235
+ ), roster AS (
17236
+ SELECT p.id, p.name, p.avatar_url, public.agenda_turn__area_of(v_tenant, p.id) AS area
17237
+ FROM public.people p
17238
+ WHERE p.tenant_id = v_tenant AND p.is_active
17239
+ AND (public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)
17240
+ OR EXISTS (SELECT 1 FROM appts t WHERE t.professional_id = p.id))
17241
+ ), served AS (
17242
+ SELECT ev.professional_id,
17243
+ count(*) FILTER (WHERE ev.event_type IN ('started', 'advanced_to_service'))::int AS atendimentos
17244
+ FROM public.plg_agenda_turn_events ev
17245
+ WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day
17246
+ GROUP BY 1
17247
+ )
17248
+ SELECT jsonb_build_object(
17249
+ 'unitId', p_unit_id,
17250
+ 'today', v_day,
17251
+ 'enabled', coalesce(public.agenda_turn__on(v_queue, 'enabled'), false),
17252
+ 'selfCheckin', coalesce(public.agenda_turn__on(v_queue, 'self_checkin'), true),
17253
+ 'auto', coalesce(public.agenda_turn__on(v_queue, 'follows_appointments'), true),
17254
+ 'requireLocation', coalesce(public.agenda_turn__on(v_queue, 'require_location'), false),
17255
+ 'locationConfigured', v_lat IS NOT NULL AND v_lng IS NOT NULL,
17256
+ 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color) ORDER BY d.sort_order, d.key)
17257
+ FROM public.agenda_turn__area_defs(v_tenant) d), '[]'::jsonb),
17258
+ 'entries', coalesce((
17259
+ SELECT jsonb_agg(jsonb_build_object(
17260
+ 'professionalId', e.professional_id, 'name', p.name, 'avatarUrl', p.avatar_url,
17261
+ 'area', coalesce(e.area_key, 'geral'), 'status', e.status,
17262
+ 'rank', CASE WHEN e.status IN ('available', 'away') THEN e.rnk END,
17263
+ 'awayReason', e.away_reason, 'since', e.state_changed_at, 'joinedAt', e.joined_at,
17264
+ 'appointmentId', e.appointment_id,
17265
+ 'servedToday', coalesce(s.atendimentos, 0)
17266
+ ) ORDER BY e.area_key, (e.status = 'serving'), e.rnk)
17267
+ FROM ent e JOIN public.people p ON p.id = e.professional_id
17268
+ LEFT JOIN served s ON s.professional_id = e.professional_id), '[]'::jsonb),
17269
+ 'roster', coalesce((
17270
+ SELECT jsonb_agg(jsonb_build_object(
17271
+ 'professionalId', r.id, 'name', r.name, 'avatarUrl', r.avatar_url, 'area', r.area,
17272
+ 'appointmentsToday', coalesce(t.n, 0), 'nextAppointmentAt', t.proximo,
17273
+ 'servedToday', coalesce(s.atendimentos, 0)) ORDER BY r.name)
17274
+ FROM roster r
17275
+ LEFT JOIN appts t ON t.professional_id = r.id
17276
+ LEFT JOIN served s ON s.professional_id = r.id
17277
+ WHERE NOT EXISTS (SELECT 1 FROM ent e WHERE e.professional_id = r.id)), '[]'::jsonb),
17278
+ 'events', coalesce((
17279
+ SELECT jsonb_agg(x.item ORDER BY x.at DESC) FROM (
17280
+ SELECT jsonb_build_object('id', ev.id, 'professionalId', ev.professional_id, 'name', ep.name,
17281
+ 'type', ev.event_type, 'origin', ev.origin, 'area', ev.area_key,
17282
+ 'reason', coalesce(ev.reason, ev.payload->>'reason'), 'createdAt', ev.occurred_at) AS item,
17283
+ ev.occurred_at AS at
17284
+ FROM public.plg_agenda_turn_events ev
17285
+ JOIN public.people ep ON ep.id = ev.professional_id
17286
+ WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day
17287
+ ORDER BY ev.occurred_at DESC LIMIT 60) x), '[]'::jsonb)
17288
+ ) INTO v_result;
17289
+ RETURN v_result;
17290
+ END $$;
17291
+
17292
+
17293
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_act(
17294
+ p_unit_id uuid,
17295
+ p_professional_id uuid,
17296
+ p_action text,
17297
+ p_position integer DEFAULT NULL,
17298
+ p_reason text DEFAULT NULL
17299
+ ) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
17300
+ DECLARE
17301
+ v_tenant uuid := app.current_tenant_id();
17302
+ v_queue public.plg_agenda_turn_queues;
17303
+ v_entry public.plg_agenda_turn_entries;
17304
+ v_need text := CASE p_action WHEN 'join' THEN 'create' WHEN 'leave' THEN 'delete' ELSE 'edit' END;
17305
+ v_reason text := nullif(btrim(coalesce(p_reason, '')), '');
17306
+ v_area text;
17307
+ v_pos bigint;
17308
+ v_ids uuid[];
17309
+ v_users uuid[];
17310
+ BEGIN
17311
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
17312
+ IF p_action NOT IN ('join', 'leave', 'away', 'back', 'move', 'start', 'finish', 'skip', 'call') THEN
17313
+ RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';
17314
+ END IF;
17315
+ IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.' || v_need, p_unit_id) THEN
17316
+ RAISE EXCEPTION 'agenda.turn_queue.% is required', v_need USING ERRCODE = '42501';
17317
+ END IF;
17318
+ IF NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = p_professional_id AND p.tenant_id = v_tenant AND p.is_active) THEN
17319
+ RAISE EXCEPTION 'turn_queue_invalid_professional' USING ERRCODE = '22023';
17320
+ END IF;
17321
+
17322
+ SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q
17323
+ WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;
17324
+ IF v_queue.id IS NULL OR NOT public.agenda_turn__on(v_queue, 'enabled') THEN
17325
+ RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023';
17326
+ END IF;
17327
+ PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));
17328
+
17329
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
17330
+ WHERE e.queue_id = v_queue.id AND e.professional_id = p_professional_id;
17331
+ IF v_entry.id IS NOT NULL AND v_entry.status = 'unavailable' THEN v_entry := NULL; END IF;
17332
+
17333
+ IF p_action = 'join' THEN
17334
+ IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17335
+ v_area := public.agenda_turn__area_of(v_tenant, p_professional_id);
17336
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_area);
17337
+ INSERT INTO public.plg_agenda_turn_entries
17338
+ (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, appointment_id, away_reason, updated_at)
17339
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'available', v_pos, v_area, now(), now(), now(), NULL, NULL, now())
17340
+ ON CONFLICT (queue_id, professional_id) DO UPDATE SET
17341
+ status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),
17342
+ state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();
17343
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
17344
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'joined', 'frontdesk', auth.uid(), v_area, jsonb_build_object('to_position', v_pos));
17345
+ RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area, 'position', v_pos);
17346
+ END IF;
17347
+
17348
+ IF p_action = 'call' THEN
17349
+ SELECT array_agg(DISTINCT m.user_id) INTO v_users FROM app.memberships m
17350
+ WHERE m.tenant_id = v_tenant AND m.person_id = p_professional_id AND m.active;
17351
+ IF v_users IS NOT NULL THEN
17352
+ INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id)
17353
+ SELECT v_tenant, u, 'Cliente para voc\xEA na recep\xE7\xE3o', coalesce(v_reason, 'Voc\xEA \xE9 o pr\xF3ximo da vez.'),
17354
+ 'turn_queue_call', '#/agenda', 'person', p_professional_id
17355
+ FROM unnest(v_users) u;
17356
+ END IF;
17357
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)
17358
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'called', 'frontdesk', auth.uid(),
17359
+ coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, p_professional_id)), v_reason,
17360
+ jsonb_build_object('notified', v_users IS NOT NULL));
17361
+ RETURN jsonb_build_object('ok', true, 'changed', true, 'notified', v_users IS NOT NULL);
17362
+ END IF;
17363
+
17364
+ IF v_entry.id IS NULL THEN RAISE EXCEPTION 'turn_queue_not_in_queue' USING ERRCODE = '22023'; END IF;
17365
+
17366
+ IF p_action = 'leave' THEN
17367
+ UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,
17368
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17369
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
17370
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
17371
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'left', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));
17372
+
17373
+ ELSIF p_action = 'away' THEN
17374
+ IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;
17375
+ UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),
17376
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17377
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)
17378
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'away', 'frontdesk', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'),
17379
+ jsonb_build_object('from_position', v_entry.position));
17380
+
17381
+ ELSIF p_action = 'back' THEN
17382
+ IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17383
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,
17384
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17385
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
17386
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'back', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_entry.position));
17387
+
17388
+ ELSIF p_action = 'move' THEN
17389
+ IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;
17390
+ IF p_position IS NULL OR p_position < 1 THEN RAISE EXCEPTION 'turn_queue_invalid_position' USING ERRCODE = '22023'; END IF;
17391
+ SELECT array_agg(id ORDER BY position, joined_at, id) INTO v_ids FROM public.plg_agenda_turn_entries
17392
+ WHERE queue_id = v_queue.id AND area_key IS NOT DISTINCT FROM v_entry.area_key
17393
+ AND status IN ('available', 'away') AND id <> v_entry.id;
17394
+ v_ids := coalesce(v_ids, ARRAY[]::uuid[]);
17395
+ v_pos := least(p_position, coalesce(array_length(v_ids, 1), 0) + 1);
17396
+ v_ids := v_ids[1:v_pos - 1] || v_entry.id || v_ids[v_pos:];
17397
+ UPDATE public.plg_agenda_turn_entries e SET position = o.ord, updated_at = now()
17398
+ FROM unnest(v_ids) WITH ORDINALITY o(id, ord) WHERE e.id = o.id;
17399
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
17400
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'moved', 'frontdesk', auth.uid(), v_entry.area_key,
17401
+ jsonb_build_object('from_position', v_entry.position, 'to_position', v_pos));
17402
+
17403
+ ELSIF p_action = 'start' THEN
17404
+ IF v_entry.status = 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17405
+ UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, previous_position = position,
17406
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17407
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
17408
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
17409
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'started', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));
17410
+
17411
+ ELSIF p_action = 'finish' THEN
17412
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);
17413
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL, appointment_id = NULL,
17414
+ position = v_pos, available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17415
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
17416
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
17417
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'finished', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));
17418
+
17419
+ ELSIF p_action = 'skip' THEN
17420
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)
17421
+ VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'skipped', 'frontdesk', auth.uid(), v_entry.area_key, v_reason,
17422
+ jsonb_build_object('from_position', v_entry.position));
17423
+ END IF;
17424
+
17425
+ RETURN jsonb_build_object('ok', true, 'changed', true);
17426
+ END $$;
17427
+
17428
+
17429
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_state(p_unit_id uuid)
17430
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
17431
+ DECLARE
17432
+ v_tenant uuid := app.current_tenant_id();
17433
+ v_person uuid;
17434
+ v_queue public.plg_agenda_turn_queues;
17435
+ v_entry public.plg_agenda_turn_entries;
17436
+ v_tz text;
17437
+ v_rank bigint;
17438
+ BEGIN
17439
+ IF v_tenant IS NULL OR auth.uid() IS NULL OR p_unit_id IS NULL THEN RETURN NULL; END IF;
17440
+ SELECT m.person_id INTO v_person FROM app.memberships m
17441
+ WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;
17442
+ IF v_person IS NULL OR NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN RETURN NULL; END IF;
17443
+ v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);
17444
+ IF v_queue.id IS NULL OR NOT public.agenda_turn__on(v_queue, 'enabled') THEN RETURN NULL; END IF;
17445
+ v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);
17446
+
17447
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
17448
+ WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable'
17449
+ AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;
17450
+ IF v_entry.id IS NOT NULL AND v_entry.status IN ('available', 'away') THEN
17451
+ SELECT count(*) + 1 INTO v_rank FROM public.plg_agenda_turn_entries o
17452
+ WHERE o.queue_id = v_queue.id AND o.area_key IS NOT DISTINCT FROM v_entry.area_key
17453
+ AND o.status IN ('available', 'away') AND o.id <> v_entry.id
17454
+ AND (o.position, o.joined_at, o.id) < (v_entry.position, v_entry.joined_at, v_entry.id);
17455
+ END IF;
17456
+ RETURN jsonb_build_object(
17457
+ 'professionalId', v_person,
17458
+ 'selfCheckin', public.agenda_turn__on(v_queue, 'self_checkin'),
17459
+ 'requireLocation', public.agenda_turn__on(v_queue, 'require_location'),
17460
+ 'inQueue', v_entry.id IS NOT NULL,
17461
+ 'area', coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, v_person)),
17462
+ 'status', v_entry.status,
17463
+ 'awayReason', v_entry.away_reason,
17464
+ 'rank', v_rank);
17465
+ END $$;
17466
+
17467
+
17468
+ CREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_action(
17469
+ p_unit_id uuid,
17470
+ p_action text,
17471
+ p_latitude double precision DEFAULT NULL,
17472
+ p_longitude double precision DEFAULT NULL,
17473
+ p_accuracy_m double precision DEFAULT NULL,
17474
+ p_reason text DEFAULT NULL
17475
+ ) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
17476
+ DECLARE
17477
+ v_tenant uuid := app.current_tenant_id();
17478
+ v_person uuid;
17479
+ v_queue public.plg_agenda_turn_queues;
17480
+ v_entry public.plg_agenda_turn_entries;
17481
+ v_reason text := nullif(btrim(coalesce(p_reason, '')), '');
17482
+ v_lat numeric;
17483
+ v_lng numeric;
17484
+ v_distance double precision;
17485
+ v_area text;
17486
+ v_pos bigint;
17487
+ BEGIN
17488
+ IF v_tenant IS NULL OR auth.uid() IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;
17489
+ IF p_action NOT IN ('join', 'leave', 'away', 'back', 'finish') THEN
17490
+ RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';
17491
+ END IF;
17492
+ SELECT m.person_id INTO v_person FROM app.memberships m
17493
+ WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;
17494
+ IF v_person IS NULL THEN RAISE EXCEPTION 'turn_queue_professional_not_linked' USING ERRCODE = '42501'; END IF;
17495
+ IF NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN
17496
+ RAISE EXCEPTION 'turn_queue_professional_outside_unit' USING ERRCODE = '42501';
17497
+ END IF;
17498
+ SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q
17499
+ WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;
17500
+ IF v_queue.id IS NULL OR NOT public.agenda_turn__on(v_queue, 'enabled') THEN RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023'; END IF;
17501
+ IF NOT public.agenda_turn__on(v_queue, 'self_checkin') THEN RAISE EXCEPTION 'turn_queue_checkin_by_frontdesk' USING ERRCODE = '22023'; END IF;
17502
+ PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));
17503
+
17504
+ SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e
17505
+ WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable';
17506
+
17507
+ IF p_action = 'join' THEN
17508
+ IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17509
+ IF public.agenda_turn__on(v_queue, 'require_location') THEN
17510
+ SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;
17511
+ IF v_lat IS NULL OR v_lng IS NULL THEN RAISE EXCEPTION 'unit_has_no_coordinates' USING ERRCODE = '22023'; END IF;
17512
+ IF p_latitude IS NULL OR p_longitude IS NULL THEN RAISE EXCEPTION 'location_required' USING ERRCODE = '22023'; END IF;
17513
+ v_distance := 6371000 * acos(least(1, greatest(-1,
17514
+ cos(radians(v_lat::double precision)) * cos(radians(p_latitude)) * cos(radians(p_longitude) - radians(v_lng::double precision))
17515
+ + sin(radians(v_lat::double precision)) * sin(radians(p_latitude)))));
17516
+ IF v_distance > public.agenda_turn__radius(v_queue) + least(coalesce(p_accuracy_m, 0), 100) THEN
17517
+ RAISE EXCEPTION 'outside_geofence' USING ERRCODE = '42501';
17518
+ END IF;
17519
+ END IF;
17520
+ v_area := public.agenda_turn__area_of(v_tenant, v_person);
17521
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_area);
17522
+ INSERT INTO public.plg_agenda_turn_entries
17523
+ (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, updated_at)
17524
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'available', v_pos, v_area, now(), now(), now(), now())
17525
+ ON CONFLICT (queue_id, professional_id) DO UPDATE SET
17526
+ status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),
17527
+ state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();
17528
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key,
17529
+ inside_geofence, location_accuracy_meters, payload)
17530
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'joined', 'app', auth.uid(), v_area,
17531
+ CASE WHEN public.agenda_turn__on(v_queue, 'require_location') THEN true END, p_accuracy_m, jsonb_build_object('to_position', v_pos));
17532
+ RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area);
17533
+ END IF;
17534
+
17535
+ IF v_entry.id IS NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17536
+
17537
+ IF p_action = 'leave' THEN
17538
+ UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,
17539
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17540
+ PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);
17541
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)
17542
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'left', 'app', auth.uid(), v_entry.area_key);
17543
+ ELSIF p_action = 'away' THEN
17544
+ IF v_entry.status <> 'available' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17545
+ UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),
17546
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17547
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason)
17548
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'away', 'app', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'));
17549
+ ELSIF p_action = 'back' THEN
17550
+ IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17551
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,
17552
+ state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17553
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)
17554
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'back', 'app', auth.uid(), v_entry.area_key);
17555
+ ELSIF p_action = 'finish' THEN
17556
+ IF v_entry.status <> 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;
17557
+ v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);
17558
+ UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,
17559
+ available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;
17560
+ INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)
17561
+ VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'finished', 'app', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));
17562
+ END IF;
17563
+ RETURN jsonb_build_object('ok', true, 'changed', true);
17564
+ END $$;
17565
+
17566
+
17567
+ REVOKE ALL ON FUNCTION public.agenda_turn__on(public.plg_agenda_turn_queues, text) FROM PUBLIC, anon, authenticated;
17568
+ REVOKE ALL ON FUNCTION public.agenda_turn__radius(public.plg_agenda_turn_queues) FROM PUBLIC, anon, authenticated;
17569
+ REVOKE ALL ON FUNCTION public.agenda_turn__unit_cfg(uuid, uuid) FROM PUBLIC, anon, authenticated;
17570
+ REVOKE ALL ON FUNCTION public.agenda_turn__put(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
17571
+ `;
15808
17572
  var MIGRATIONS = [
15809
17573
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
15810
17574
  { id: "001_the_connector_definers_keep_a_pinned_path", sql: MIGRATION_001_THE_CONNECTOR_DEFINERS_KEEP_A_PINNED_PATH },
@@ -15826,7 +17590,9 @@ var MIGRATIONS = [
15826
17590
  { id: "017_a_equipe_do_dia_aparece", sql: MIGRATION_017_A_EQUIPE_DO_DIA_APARECE },
15827
17591
  { id: "018_o_agendamento_que_se_repete", sql: MIGRATION_018_O_AGENDAMENTO_QUE_SE_REPETE },
15828
17592
  { id: "019_a_serie_nasce_numa_viagem_so", sql: MIGRATION_019_A_SERIE_NASCE_NUMA_VIAGEM_SO },
15829
- { id: "020_a_lista_da_vez_se_liga_no_cadastro_da_unidade", sql: MIGRATION_020_A_LISTA_DA_VEZ_SE_LIGA_NO_CADASTRO_DA_UNIDADE }
17593
+ { id: "020_a_lista_da_vez_se_liga_no_cadastro_da_unidade", sql: MIGRATION_020_A_LISTA_DA_VEZ_SE_LIGA_NO_CADASTRO_DA_UNIDADE },
17594
+ { id: "021_a_vez_por_area", sql: MIGRATION_021_A_VEZ_POR_AREA },
17595
+ { id: "022_a_chave_da_fila_e_configuracao_da_unidade", sql: MIGRATION_022_A_CHAVE_DA_FILA_E_CONFIGURACAO_DA_UNIDADE }
15830
17596
  ];
15831
17597
  var channelSequence = 0;
15832
17598
  function client() {
@@ -15892,6 +17658,66 @@ function createSupabaseTurnQueueProvider() {
15892
17658
  if (error) throw error;
15893
17659
  return data ?? { ok: false };
15894
17660
  },
17661
+ async getBoard(unitId) {
17662
+ const { data, error } = await client().rpc("agenda_turn_queue_board", { p_unit_id: unitId });
17663
+ if (error) throw error;
17664
+ return data;
17665
+ },
17666
+ async act({ unitId, professionalId, action, position: position2, reason }) {
17667
+ const { data, error } = await client().rpc("agenda_turn_queue_act", {
17668
+ p_unit_id: unitId,
17669
+ p_professional_id: professionalId,
17670
+ p_action: action,
17671
+ p_position: position2 ?? null,
17672
+ p_reason: reason ?? null
17673
+ });
17674
+ if (error) throw error;
17675
+ return data ?? { ok: true };
17676
+ },
17677
+ async getConfig(unitId) {
17678
+ const { data, error } = await client().rpc("agenda_turn_queue_config", { p_unit_id: unitId });
17679
+ if (error) throw error;
17680
+ return data;
17681
+ },
17682
+ async saveConfig(unitId, patch) {
17683
+ const { error } = await client().rpc("agenda_turn_queue_save_config", { p_unit_id: unitId, p_config: patch });
17684
+ if (error) throw error;
17685
+ },
17686
+ async getMyState(unitId) {
17687
+ const { data, error } = await client().rpc("agenda_turn_queue_my_state", { p_unit_id: unitId });
17688
+ if (error) throw error;
17689
+ return data ?? null;
17690
+ },
17691
+ async myAction({ unitId, action, latitude, longitude, accuracyMeters, reason }) {
17692
+ const { data, error } = await client().rpc("agenda_turn_queue_my_action", {
17693
+ p_unit_id: unitId,
17694
+ p_action: action,
17695
+ p_latitude: latitude ?? null,
17696
+ p_longitude: longitude ?? null,
17697
+ p_accuracy_m: accuracyMeters ?? null,
17698
+ p_reason: reason ?? null
17699
+ });
17700
+ if (error) throw error;
17701
+ return data ?? { ok: true };
17702
+ },
17703
+ async getReport({ unitIds, from, to }) {
17704
+ const { data, error } = await client().rpc("agenda_turn_queue_report", { p_unit_ids: unitIds, p_from: from, p_to: to });
17705
+ if (error) throw error;
17706
+ return (data ?? []).map((row) => ({
17707
+ unitId: row.unit_id,
17708
+ unitName: row.unit_name ?? "",
17709
+ professionalId: row.professional_id,
17710
+ professionalName: row.professional_name ?? "Profissional",
17711
+ area: row.area ?? null,
17712
+ entradas: Number(row.entradas ?? 0),
17713
+ atendimentos: Number(row.atendimentos ?? 0),
17714
+ servicosRapidos: Number(row.servicos_rapidos ?? 0),
17715
+ chamados: Number(row.chamados ?? 0),
17716
+ pulos: Number(row.pulos ?? 0),
17717
+ ausencias: Number(row.ausencias ?? 0),
17718
+ minutosAusente: Number(row.minutos_ausente ?? 0)
17719
+ }));
17720
+ },
15895
17721
  subscribe(onChange) {
15896
17722
  const db = client();
15897
17723
  const channelName = `agenda:turn-queue:${getActiveTenantId() ?? "active"}:${++channelSequence}`;
@@ -15938,56 +17764,195 @@ function createMockTurnQueueProvider() {
15938
17764
  listeners2.forEach((listener) => listener());
15939
17765
  return { ok: true, status: row.status };
15940
17766
  },
17767
+ async getBoard(unitId) {
17768
+ return {
17769
+ unitId,
17770
+ today: now().slice(0, 10),
17771
+ enabled: true,
17772
+ selfCheckin: true,
17773
+ auto: true,
17774
+ requireLocation: false,
17775
+ locationConfigured: true,
17776
+ areas: [
17777
+ { key: "cabelo", name: "Cabelo", color: "amber" },
17778
+ { key: "unhas", name: "Unhas", color: "green" },
17779
+ { key: "estetica", name: "Est\xE9tica", color: "blue" },
17780
+ { key: "geral", name: "Outros", color: "slate" }
17781
+ ],
17782
+ entries: entries.filter((entry) => entry.status !== "unavailable").map((entry, index) => ({
17783
+ professionalId: entry.professionalId,
17784
+ name: entry.professionalName,
17785
+ avatarUrl: null,
17786
+ area: "cabelo",
17787
+ status: entry.status === "serving" ? "serving" : "available",
17788
+ rank: entry.status === "serving" ? null : index + 1,
17789
+ awayReason: null,
17790
+ since: entry.stateChangedAt,
17791
+ joinedAt: entry.availableAt,
17792
+ appointmentId: null,
17793
+ servedToday: 0
17794
+ })),
17795
+ roster: [{ professionalId: "prof-3", name: "Bia Costa", avatarUrl: null, area: "unhas", appointmentsToday: 1, nextAppointmentAt: now(), servedToday: 0 }],
17796
+ events: []
17797
+ };
17798
+ },
17799
+ async act() {
17800
+ listeners2.forEach((listener) => listener());
17801
+ return { ok: true, changed: true };
17802
+ },
17803
+ async getConfig() {
17804
+ return {
17805
+ unit: { auto: true, selfCheckin: true, requireLocation: false },
17806
+ areas: [
17807
+ { key: "cabelo", name: "Cabelo", color: "amber", keywords: ["cabel", "color", "escov", "penteado", "barb"] },
17808
+ { key: "unhas", name: "Unhas", color: "green", keywords: ["manicur", "pedicur", "unha"] },
17809
+ { key: "estetica", name: "Est\xE9tica", color: "blue", keywords: ["estet", "depil", "sobrancel", "massag", "cili", "maqui"] }
17810
+ ],
17811
+ professionals: professionals.map((p) => ({ professionalId: p.professionalId, name: p.professionalName, manualArea: null, autoArea: "geral" })),
17812
+ quickServiceIds: [],
17813
+ services: [{ id: "svc-1", name: "Esmalta\xE7\xE3o" }, { id: "svc-2", name: "Corte" }]
17814
+ };
17815
+ },
17816
+ async saveConfig() {
17817
+ },
17818
+ async getMyState() {
17819
+ return null;
17820
+ },
17821
+ async myAction() {
17822
+ return { ok: true, changed: true };
17823
+ },
17824
+ async getReport() {
17825
+ return [];
17826
+ },
15941
17827
  subscribe(onChange) {
15942
17828
  listeners2.add(onChange);
15943
17829
  return () => listeners2.delete(onChange);
15944
17830
  }
15945
17831
  };
15946
17832
  }
17833
+
17834
+ // src/views/turn-queue/turn-queue-ui.ts
17835
+ var AWAY_REASONS = ["Banheiro", "Lanche", "Almo\xE7o", "Saiu da loja", "Servi\xE7o interno"];
17836
+ var AREA_COLORS = [
17837
+ { value: "amber", label: "Amarelo" },
17838
+ { value: "green", label: "Verde" },
17839
+ { value: "blue", label: "Azul" },
17840
+ { value: "pink", label: "Rosa" },
17841
+ { value: "purple", label: "Roxo" },
17842
+ { value: "orange", label: "Laranja" },
17843
+ { value: "teal", label: "Verde-\xE1gua" },
17844
+ { value: "slate", label: "Cinza" }
17845
+ ];
17846
+ var AREA_TONE = {
17847
+ amber: { dot: "bg-amber-400", column: "bg-amber-50/70 border-amber-200/70 dark:bg-amber-500/5 dark:border-amber-500/20", stripe: "bg-amber-400", button: "bg-amber-400 text-amber-950 hover:bg-amber-500", text: "text-amber-600 dark:text-amber-400" },
17848
+ green: { dot: "bg-emerald-500", column: "bg-emerald-50/70 border-emerald-200/70 dark:bg-emerald-500/5 dark:border-emerald-500/20", stripe: "bg-emerald-500", button: "bg-emerald-500 text-white hover:bg-emerald-600", text: "text-emerald-600 dark:text-emerald-400" },
17849
+ blue: { dot: "bg-sky-500", column: "bg-sky-50/70 border-sky-200/70 dark:bg-sky-500/5 dark:border-sky-500/20", stripe: "bg-sky-500", button: "bg-sky-500 text-white hover:bg-sky-600", text: "text-sky-600 dark:text-sky-400" },
17850
+ pink: { dot: "bg-pink-500", column: "bg-pink-50/70 border-pink-200/70 dark:bg-pink-500/5 dark:border-pink-500/20", stripe: "bg-pink-500", button: "bg-pink-500 text-white hover:bg-pink-600", text: "text-pink-600 dark:text-pink-400" },
17851
+ purple: { dot: "bg-violet-500", column: "bg-violet-50/70 border-violet-200/70 dark:bg-violet-500/5 dark:border-violet-500/20", stripe: "bg-violet-500", button: "bg-violet-500 text-white hover:bg-violet-600", text: "text-violet-600 dark:text-violet-400" },
17852
+ orange: { dot: "bg-orange-500", column: "bg-orange-50/70 border-orange-200/70 dark:bg-orange-500/5 dark:border-orange-500/20", stripe: "bg-orange-500", button: "bg-orange-500 text-white hover:bg-orange-600", text: "text-orange-600 dark:text-orange-400" },
17853
+ teal: { dot: "bg-teal-500", column: "bg-teal-50/70 border-teal-200/70 dark:bg-teal-500/5 dark:border-teal-500/20", stripe: "bg-teal-500", button: "bg-teal-500 text-white hover:bg-teal-600", text: "text-teal-600 dark:text-teal-400" },
17854
+ slate: { dot: "bg-slate-400", column: "bg-slate-50/80 border-slate-200/80 dark:bg-slate-500/5 dark:border-slate-500/20", stripe: "bg-slate-400", button: "bg-slate-600 text-white hover:bg-slate-700", text: "text-slate-600 dark:text-slate-300" }
17855
+ };
17856
+ function toneOf(color) {
17857
+ return AREA_TONE[color in AREA_TONE ? color : "slate"];
17858
+ }
17859
+ function firstName(name) {
17860
+ return name.trim().split(/\s+/)[0] ?? name;
17861
+ }
17862
+ function minutesSince(iso) {
17863
+ if (!iso) return 0;
17864
+ return Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 6e4));
17865
+ }
17866
+ function sinceLabel(iso) {
17867
+ const m = minutesSince(iso);
17868
+ if (m < 1) return "agora";
17869
+ if (m < 60) return `${m} min`;
17870
+ return `${Math.floor(m / 60)}h${m % 60 ? ` ${String(m % 60).padStart(2, "0")}min` : ""}`;
17871
+ }
17872
+ function clock(iso) {
17873
+ return new Date(iso).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
17874
+ }
17875
+ function eventPhrase(event) {
17876
+ const by = event.origin === "frontdesk" ? " pela recep\xE7\xE3o" : "";
17877
+ const reason = event.reason ? event.reason : null;
17878
+ switch (event.type) {
17879
+ case "joined":
17880
+ case "available":
17881
+ case "override_available":
17882
+ return `entrou na fila${by}`;
17883
+ case "left":
17884
+ case "override_left":
17885
+ return `saiu da fila${by}`;
17886
+ case "away":
17887
+ return `saiu${by} (ausente)${reason ? ` \u2014 ${reason}` : ""}`;
17888
+ case "back":
17889
+ return `voltou${by}`;
17890
+ case "skipped":
17891
+ return `foi pulado${by}${reason ? ` \u2014 ausente (${reason})` : ""}`;
17892
+ case "called":
17893
+ return `foi chamado${by}`;
17894
+ case "started":
17895
+ case "advanced_to_service":
17896
+ return `come\xE7ou um atendimento${by}`;
17897
+ case "finished":
17898
+ case "service_completed":
17899
+ return `terminou e voltou para o fim${by}`;
17900
+ case "service_cancelled_restored":
17901
+ return "teve o atendimento cancelado e voltou ao lugar";
17902
+ case "moved":
17903
+ return `mudou de posi\xE7\xE3o${by}`;
17904
+ case "quick_service":
17905
+ return "fez um servi\xE7o r\xE1pido e continuou na vez";
17906
+ case "reset":
17907
+ return "saiu da fila na virada do dia";
17908
+ default:
17909
+ return "movimentou a fila";
17910
+ }
17911
+ }
17912
+ function turnErrorMessage(error) {
17913
+ const msg = String(error?.message ?? error ?? "");
17914
+ if (msg.includes("agenda.turn_queue.read")) return "Voc\xEA n\xE3o tem acesso \xE0 lista da vez.";
17915
+ if (msg.includes("agenda.turn_queue.")) return "Seu perfil n\xE3o pode fazer isso na lista da vez.";
17916
+ if (msg.includes("turn_queue_disabled")) return "A lista da vez est\xE1 desligada nesta unidade.";
17917
+ if (msg.includes("turn_queue_in_service")) return "Quem est\xE1 em atendimento n\xE3o pode sair nem mudar de lugar agora.";
17918
+ if (msg.includes("turn_queue_not_in_queue")) return "Esta pessoa n\xE3o est\xE1 mais na fila.";
17919
+ if (msg.includes("outside_geofence")) return "Voc\xEA est\xE1 fora do raio da loja. Quando chegar, aperte de novo.";
17920
+ if (msg.includes("unit_has_no_coordinates")) return "A loja ainda n\xE3o tem localiza\xE7\xE3o cadastrada.";
17921
+ if (msg.includes("location_required") || msg.includes("geolocation")) return "Precisamos da localiza\xE7\xE3o do celular para confirmar que voc\xEA est\xE1 na loja.";
17922
+ if (msg.includes("turn_queue_checkin_by_frontdesk")) return "Nesta unidade quem coloca na fila \xE9 a recep\xE7\xE3o.";
17923
+ if (msg.includes("turn_queue_professional_not_linked")) return "Seu acesso n\xE3o est\xE1 vinculado a um cadastro de profissional.";
17924
+ if (msg.includes("turn_queue_professional_outside_unit")) return "Seu cadastro de profissional n\xE3o \xE9 desta unidade.";
17925
+ return "N\xE3o foi poss\xEDvel concluir agora. Tente de novo.";
17926
+ }
15947
17927
  function position() {
15948
17928
  return new Promise((resolve, reject) => {
15949
17929
  if (!navigator.geolocation) {
15950
17930
  reject(new Error("geolocation_unavailable"));
15951
17931
  return;
15952
17932
  }
15953
- navigator.geolocation.getCurrentPosition(resolve, reject, {
15954
- enableHighAccuracy: true,
15955
- timeout: 12e3,
15956
- maximumAge: 3e4
15957
- });
17933
+ navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 12e3, maximumAge: 3e4 });
15958
17934
  });
15959
17935
  }
15960
- function failureMessage(error) {
15961
- const message = String(error?.message ?? error ?? "");
15962
- if (message.includes("outside_geofence")) {
15963
- return "Sua localiza\xE7\xE3o n\xE3o permite dizer que est\xE1 dispon\xEDvel ainda. Quando chegar na loja, aperte novamente.";
15964
- }
15965
- if (message.includes("unit_has_no_coordinates")) {
15966
- return "A unidade ainda n\xE3o tem localiza\xE7\xE3o configurada para validar sua disponibilidade.";
15967
- }
15968
- if (message.includes("sem_ficha_de_profissional")) {
15969
- return "Seu acesso ainda n\xE3o est\xE1 vinculado a uma ficha de profissional.";
15970
- }
15971
- if (message.includes("unit_required")) return "Escolha a unidade antes de marcar disponibilidade.";
15972
- return "N\xE3o foi poss\xEDvel validar sua disponibilidade agora.";
15973
- }
15974
17936
  function AvailabilityHeaderControl({ provider: provider2 }) {
15975
17937
  const unitId = useActiveUnitId();
15976
- const [status, setStatus] = React23.useState(null);
17938
+ const [me, setMe] = React23.useState(null);
15977
17939
  const [busy, setBusy] = React23.useState(false);
15978
17940
  const refresh = React23.useCallback(async () => {
15979
17941
  if (!unitId) {
15980
- setStatus(null);
17942
+ setMe(null);
15981
17943
  return;
15982
17944
  }
15983
17945
  try {
15984
- const snapshot = await provider2.getSnapshot(unitId);
15985
- const own = snapshot.entries.find((entry) => entry.professionalId === snapshot.currentProfessionalId);
15986
- const leader = [...snapshot.entries].filter((entry) => entry.status === "available").sort((a, b) => a.position - b.position)[0];
15987
- publishTurnQueueLeader(unitId, leader?.professionalId ?? null);
15988
- setStatus(own?.status ?? "unavailable");
17946
+ setMe(await provider2.getMyState(unitId));
17947
+ } catch {
17948
+ setMe(null);
17949
+ }
17950
+ try {
17951
+ const board = await provider2.getBoard(unitId);
17952
+ const leader = board.entries.filter((e) => e.status === "available").sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))[0];
17953
+ publishTurnQueueLeader(unitId, board.enabled ? leader?.professionalId ?? null : null);
15989
17954
  } catch {
15990
- setStatus(null);
17955
+ publishTurnQueueLeader(unitId, null);
15991
17956
  }
15992
17957
  }, [provider2, unitId]);
15993
17958
  React23.useEffect(() => {
@@ -15996,316 +17961,951 @@ function AvailabilityHeaderControl({ provider: provider2 }) {
15996
17961
  void refresh();
15997
17962
  });
15998
17963
  }, [provider2, refresh]);
15999
- async function toggle() {
16000
- if (!unitId) {
16001
- toast.error("Escolha a unidade antes de marcar disponibilidade.");
16002
- return;
16003
- }
17964
+ if (!unitId || !me || !me.selfCheckin) return null;
17965
+ async function run(action, reason) {
17966
+ if (!unitId || !me) return;
16004
17967
  setBusy(true);
16005
17968
  try {
16006
- if (status === "available") {
16007
- const result = await provider2.setAvailability({ available: false, unitId });
16008
- if (!result.ok) throw new Error(result.reason);
16009
- setStatus("unavailable");
16010
- toast.success("Voc\xEA saiu da vez.");
16011
- } else {
16012
- const geo = await position();
16013
- const result = await provider2.setAvailability({
16014
- available: true,
16015
- unitId,
16016
- latitude: geo.coords.latitude,
16017
- longitude: geo.coords.longitude,
16018
- accuracyMeters: geo.coords.accuracy
16019
- });
16020
- if (!result.ok) throw new Error(result.reason);
16021
- setStatus("available");
16022
- toast.success("Voc\xEA est\xE1 dispon\xEDvel e entrou no fim da fila.");
17969
+ let geo = null;
17970
+ if (action === "join" && me.requireLocation) geo = await position();
17971
+ await provider2.myAction({
17972
+ unitId,
17973
+ action,
17974
+ reason,
17975
+ latitude: geo?.coords.latitude,
17976
+ longitude: geo?.coords.longitude,
17977
+ accuracyMeters: geo?.coords.accuracy
17978
+ });
17979
+ const said = {
17980
+ join: "Voc\xEA entrou no fim da fila.",
17981
+ leave: "Voc\xEA saiu da fila.",
17982
+ away: "Seu lugar fica guardado.",
17983
+ back: "Voc\xEA voltou para o seu lugar.",
17984
+ finish: "Voc\xEA voltou para o fim da fila."
17985
+ };
17986
+ toast.success(said[action]);
17987
+ await refresh();
17988
+ } catch (cause) {
17989
+ toast.error(turnErrorMessage(cause));
17990
+ } finally {
17991
+ setBusy(false);
17992
+ }
17993
+ }
17994
+ if (!me.inQueue) {
17995
+ return /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", className: "h-9 rounded-full", disabled: busy, onClick: () => {
17996
+ void run("join");
17997
+ }, children: [
17998
+ busy ? /* @__PURE__ */ jsx(Loader2, { className: "mr-1.5 h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx(LogIn, { className: "mr-1.5 h-4 w-4" }),
17999
+ "Cheguei"
18000
+ ] });
18001
+ }
18002
+ const label = me.status === "serving" ? "Em atendimento" : me.status === "away" ? me.awayReason ?? "Ausente" : `Na vez \xB7 ${me.rank ?? "\u2013"}\xBA`;
18003
+ return /* @__PURE__ */ jsxs(Dropdown, { children: [
18004
+ /* @__PURE__ */ jsx(DropdownTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", className: "h-9 rounded-full", disabled: busy, children: [
18005
+ busy ? /* @__PURE__ */ jsx(Loader2, { className: "mr-1.5 h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: `mr-2 h-2 w-2 rounded-full ${me.status === "away" ? "bg-destructive" : me.status === "serving" ? "bg-sky-500" : "bg-success"}` }),
18006
+ label
18007
+ ] }) }),
18008
+ /* @__PURE__ */ jsxs(DropdownContent, { align: "end", className: "w-56", children: [
18009
+ /* @__PURE__ */ jsx(DropdownLabel, { children: "Lista da vez" }),
18010
+ me.status === "available" ? /* @__PURE__ */ jsxs(DropdownSub, { children: [
18011
+ /* @__PURE__ */ jsxs(DropdownSubTrigger, { children: [
18012
+ /* @__PURE__ */ jsx(Coffee, { className: "mr-2 h-4 w-4" }),
18013
+ "Sa\xED (guarda o lugar)"
18014
+ ] }),
18015
+ /* @__PURE__ */ jsx(DropdownSubContent, { children: AWAY_REASONS.map((reason) => /* @__PURE__ */ jsx(DropdownItem, { onSelect: () => {
18016
+ void run("away", reason);
18017
+ }, children: reason }, reason)) })
18018
+ ] }) : null,
18019
+ me.status === "away" ? /* @__PURE__ */ jsxs(DropdownItem, { onSelect: () => {
18020
+ void run("back");
18021
+ }, children: [
18022
+ /* @__PURE__ */ jsx(Undo2, { className: "mr-2 h-4 w-4" }),
18023
+ "Voltei"
18024
+ ] }) : null,
18025
+ me.status === "serving" ? /* @__PURE__ */ jsxs(DropdownItem, { onSelect: () => {
18026
+ void run("finish");
18027
+ }, children: [
18028
+ /* @__PURE__ */ jsx(CheckCircle2, { className: "mr-2 h-4 w-4" }),
18029
+ "Terminei"
18030
+ ] }) : null,
18031
+ /* @__PURE__ */ jsx(DropdownSeparator, {}),
18032
+ /* @__PURE__ */ jsxs(DropdownItem, { className: "text-destructive focus:text-destructive", onSelect: () => {
18033
+ void run("leave");
18034
+ }, children: [
18035
+ /* @__PURE__ */ jsx(LogOut, { className: "mr-2 h-4 w-4" }),
18036
+ "Sair da fila"
18037
+ ] })
18038
+ ] })
18039
+ ] });
18040
+ }
18041
+ var QUICK_HINTS = ["esmalt", "piastra", "chapinha", "secagem", "lavagem", "avaliac", "retoque"];
18042
+ var AUTO = "__auto__";
18043
+ function norm(value) {
18044
+ return value.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
18045
+ }
18046
+ function slug(name, taken) {
18047
+ const base = norm(name).replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "").slice(0, 24) || "area";
18048
+ let key = base;
18049
+ for (let i = 2; taken.has(key) || key === "geral"; i++) key = `${base}_${i}`;
18050
+ return key;
18051
+ }
18052
+ function SwitchRow({ title, hint, checked, onChange }) {
18053
+ return /* @__PURE__ */ jsxs("label", { className: "flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-border/70 p-4", children: [
18054
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0", children: [
18055
+ /* @__PURE__ */ jsx("span", { className: "block text-sm font-medium text-foreground", children: title }),
18056
+ /* @__PURE__ */ jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: hint })
18057
+ ] }),
18058
+ /* @__PURE__ */ jsx(Switch, { checked, onCheckedChange: onChange })
18059
+ ] });
18060
+ }
18061
+ function IconButton({ label, disabled, onClick, children, destructive }) {
18062
+ return /* @__PURE__ */ jsxs(Tooltip, { children: [
18063
+ /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
18064
+ Button,
18065
+ {
18066
+ type: "button",
18067
+ variant: "ghost",
18068
+ size: "icon",
18069
+ "aria-label": label,
18070
+ disabled,
18071
+ onClick,
18072
+ className: cn("h-9 w-9 shrink-0", destructive && "text-destructive hover:text-destructive"),
18073
+ children
16023
18074
  }
16024
- } catch (error) {
16025
- toast.error(failureMessage(error));
18075
+ ) }),
18076
+ /* @__PURE__ */ jsx(TooltipContent, { children: label })
18077
+ ] });
18078
+ }
18079
+ function TurnQueueConfigDialog({ provider: provider2, unitId, onClose, onSaved }) {
18080
+ const [config, setConfig] = React23.useState(null);
18081
+ const [unit, setUnit] = React23.useState({ auto: true, selfCheckin: true, requireLocation: false });
18082
+ const [areas, setAreas] = React23.useState([]);
18083
+ const [pinned, setPinned] = React23.useState({});
18084
+ const [quick, setQuick] = React23.useState(/* @__PURE__ */ new Set());
18085
+ const [search, setSearch] = React23.useState("");
18086
+ const [saving, setSaving] = React23.useState(false);
18087
+ const [error, setError] = React23.useState(null);
18088
+ React23.useEffect(() => {
18089
+ let alive = true;
18090
+ provider2.getConfig(unitId).then((next) => {
18091
+ if (!alive) return;
18092
+ setConfig(next);
18093
+ setUnit(next.unit);
18094
+ setAreas(next.areas.map((area) => ({ ...area, keywordsText: area.keywords.join(", ") })));
18095
+ setPinned(Object.fromEntries(next.professionals.map((p) => [p.professionalId, p.manualArea])));
18096
+ setQuick(new Set(next.quickServiceIds));
18097
+ }).catch((cause) => {
18098
+ if (alive) setError(turnErrorMessage(cause));
18099
+ });
18100
+ return () => {
18101
+ alive = false;
18102
+ };
18103
+ }, [provider2, unitId]);
18104
+ const areaOptions = React23.useMemo(() => [
18105
+ ...areas.filter((area) => area.name.trim()).map((area) => ({ key: area.key, name: area.name.trim() })),
18106
+ { key: "geral", name: "Outros" }
18107
+ ], [areas]);
18108
+ const areaName = (key) => areaOptions.find((a) => a.key === key)?.name ?? "Outros";
18109
+ const services = React23.useMemo(() => {
18110
+ const q = norm(search.trim());
18111
+ return (config?.services ?? []).filter((s) => !q || norm(s.name).includes(q));
18112
+ }, [config, search]);
18113
+ const moveArea = (index, delta) => setAreas((list) => {
18114
+ const next = [...list];
18115
+ const [row] = next.splice(index, 1);
18116
+ next.splice(index + delta, 0, row);
18117
+ return next;
18118
+ });
18119
+ const suggest = () => {
18120
+ const hits = (config?.services ?? []).filter((s) => QUICK_HINTS.some((hint) => norm(s.name).includes(hint)));
18121
+ setQuick((prev) => /* @__PURE__ */ new Set([...prev, ...hits.map((s) => s.id)]));
18122
+ toast.success(`${hits.length} servi\xE7o(s) marcado(s) pelo nome. Confira antes de salvar.`);
18123
+ };
18124
+ const blank = areas.find((area) => !area.name.trim());
18125
+ const save = async () => {
18126
+ if (blank) {
18127
+ setError("D\xEA um nome para cada \xE1rea (ou exclua a linha vazia).");
18128
+ return;
18129
+ }
18130
+ setSaving(true);
18131
+ setError(null);
18132
+ try {
18133
+ const initial = Object.fromEntries((config?.professionals ?? []).map((p) => [p.professionalId, p.manualArea]));
18134
+ const changedPins = Object.fromEntries(Object.entries(pinned).filter(([id, area]) => initial[id] !== area));
18135
+ await provider2.saveConfig(unitId, {
18136
+ unit,
18137
+ areas: areas.map(({ keywordsText, ...area }) => ({
18138
+ ...area,
18139
+ name: area.name.trim(),
18140
+ keywords: keywordsText.split(",").map((k) => k.trim()).filter(Boolean)
18141
+ })),
18142
+ professionalAreas: changedPins,
18143
+ quickServiceIds: [...quick]
18144
+ });
18145
+ toast.success("Configura\xE7\xE3o da lista da vez salva.");
18146
+ onSaved();
18147
+ } catch (cause) {
18148
+ setError(turnErrorMessage(cause));
16026
18149
  } finally {
16027
- setBusy(false);
16028
- }
16029
- }
16030
- const available = status === "available";
16031
- return /* @__PURE__ */ jsx(PermissionGate, { feature: "agenda", action: "edit", children: /* @__PURE__ */ jsxs(
16032
- "button",
16033
- {
16034
- type: "button",
16035
- role: "switch",
16036
- "aria-checked": available,
16037
- className: "inline-flex min-h-11 items-center gap-2 rounded-full px-1.5 text-xs font-medium text-foreground transition-opacity disabled:cursor-not-allowed disabled:opacity-60 md:hidden",
16038
- disabled: busy,
16039
- onClick: () => {
16040
- void toggle();
16041
- },
16042
- "aria-label": available ? "Sair da vez" : "Estou dispon\xEDvel",
16043
- children: [
16044
- /* @__PURE__ */ jsx("span", { className: "hidden min-[390px]:inline", children: "Dispon\xEDvel" }),
16045
- /* @__PURE__ */ jsx(
16046
- "span",
16047
- {
16048
- "aria-hidden": true,
16049
- className: `relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${available ? "bg-success" : "bg-muted-foreground/35"}`,
16050
- children: /* @__PURE__ */ jsx("span", { className: `inline-flex h-5 w-5 items-center justify-center rounded-full bg-white shadow-sm transition-transform ${available ? "translate-x-[1.375rem]" : "translate-x-0.5"}`, children: busy && /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin text-muted-foreground" }) })
16051
- }
16052
- )
16053
- ]
18150
+ setSaving(false);
16054
18151
  }
16055
- ) });
16056
- }
16057
- function initials(name) {
16058
- return name.split(/\s+/).slice(0, 2).map((part) => part[0]).join("").toUpperCase();
16059
- }
16060
- function elapsed(iso) {
16061
- const minutes = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 6e4));
16062
- if (minutes < 1) return "agora";
16063
- if (minutes < 60) return `h\xE1 ${minutes} min`;
16064
- const hours = Math.floor(minutes / 60);
16065
- return `h\xE1 ${hours}h${minutes % 60 ? ` ${minutes % 60}min` : ""}`;
18152
+ };
18153
+ return /* @__PURE__ */ jsx(Modal, { open: true, onOpenChange: (open) => {
18154
+ if (!open) onClose();
18155
+ }, children: /* @__PURE__ */ jsx(ModalContent, { size: "2xl", className: "flex max-h-[min(44rem,calc(100dvh-2rem))] flex-col", children: /* @__PURE__ */ jsxs(TooltipProvider, { delayDuration: 200, children: [
18156
+ /* @__PURE__ */ jsxs(ModalHeader, { children: [
18157
+ /* @__PURE__ */ jsx(ModalTitle, { children: "Configurar a lista da vez" }),
18158
+ /* @__PURE__ */ jsx(ModalDescription, { children: "Ligar a fila e o raio da loja ficam no cadastro da unidade, aba Lista da vez." })
18159
+ ] }),
18160
+ !config ? /* @__PURE__ */ jsx(ModalBody, { className: "space-y-3", children: error ? /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: error }) : /* @__PURE__ */ jsxs(Fragment, { children: [
18161
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-full rounded-lg" }),
18162
+ [0, 1, 2].map((i) => /* @__PURE__ */ jsx(Skeleton, { className: "h-16 w-full rounded-xl" }, i))
18163
+ ] }) }) : /* @__PURE__ */ jsxs(Tabs, { defaultValue: "unit", className: "flex min-h-0 flex-1 flex-col", children: [
18164
+ /* @__PURE__ */ jsx("div", { className: "px-6", children: /* @__PURE__ */ jsxs(TabsList, { className: "grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4", children: [
18165
+ /* @__PURE__ */ jsx(TabsTrigger, { value: "unit", children: "Unidade" }),
18166
+ /* @__PURE__ */ jsx(TabsTrigger, { value: "areas", children: "\xC1reas" }),
18167
+ /* @__PURE__ */ jsx(TabsTrigger, { value: "people", children: "Profissionais" }),
18168
+ /* @__PURE__ */ jsx(TabsTrigger, { value: "quick", children: "Servi\xE7os r\xE1pidos" })
18169
+ ] }) }),
18170
+ /* @__PURE__ */ jsxs(ModalBody, { className: "min-h-0 flex-1 overflow-y-auto", children: [
18171
+ /* @__PURE__ */ jsxs(TabsContent, { value: "unit", className: "mt-0 space-y-3", children: [
18172
+ /* @__PURE__ */ jsx(
18173
+ SwitchRow,
18174
+ {
18175
+ title: "A fila anda sozinha pelo atendimento",
18176
+ hint: "Ao iniciar o atendimento na agenda o profissional sai da vez; ao concluir, volta para o fim.",
18177
+ checked: unit.auto,
18178
+ onChange: (auto) => setUnit((u) => ({ ...u, auto }))
18179
+ }
18180
+ ),
18181
+ /* @__PURE__ */ jsx(
18182
+ SwitchRow,
18183
+ {
18184
+ title: "O profissional faz o pr\xF3prio check-in",
18185
+ hint: 'Bot\xE3o "Cheguei" no topo da tela, para quem tem cadastro de profissional nesta unidade e est\xE1 logado nela.',
18186
+ checked: unit.selfCheckin,
18187
+ onChange: (selfCheckin) => setUnit((u) => ({ ...u, selfCheckin }))
18188
+ }
18189
+ ),
18190
+ /* @__PURE__ */ jsx(
18191
+ SwitchRow,
18192
+ {
18193
+ title: "Exigir que o profissional esteja na loja",
18194
+ hint: 'O "Cheguei" s\xF3 vale dentro do raio da loja (pede a localiza\xE7\xE3o do celular). Desligado, estar logado na unidade basta.',
18195
+ checked: unit.requireLocation,
18196
+ onChange: (requireLocation) => setUnit((u) => ({ ...u, requireLocation }))
18197
+ }
18198
+ )
18199
+ ] }),
18200
+ /* @__PURE__ */ jsxs(TabsContent, { value: "areas", className: "mt-0 space-y-3", children: [
18201
+ /* @__PURE__ */ jsx(NetworkNotice, {}),
18202
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: 'Uma fila por \xE1rea. O profissional cai na primeira \xE1rea (de cima para baixo) cuja palavra aparece na especialidade do cadastro. Quem n\xE3o casar com nenhuma fica em "Outros".' }),
18203
+ areas.map((area, index) => /* @__PURE__ */ jsxs("div", { className: "space-y-2 rounded-xl border border-border/70 p-3", children: [
18204
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
18205
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-[12rem] flex-1 items-center gap-2", children: [
18206
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("h-2.5 w-2.5 shrink-0 rounded-full", toneOf(area.color).dot) }),
18207
+ /* @__PURE__ */ jsx(
18208
+ Input,
18209
+ {
18210
+ "aria-label": "Nome da \xE1rea",
18211
+ value: area.name,
18212
+ placeholder: "Nome da \xE1rea",
18213
+ onChange: (e) => setAreas((list) => list.map((a, i) => i === index ? { ...a, name: e.target.value } : a))
18214
+ }
18215
+ )
18216
+ ] }),
18217
+ /* @__PURE__ */ jsxs(Select, { value: area.color, onValueChange: (color) => setAreas((list) => list.map((a, i) => i === index ? { ...a, color } : a)), children: [
18218
+ /* @__PURE__ */ jsx(SelectTrigger, { className: "w-32 shrink-0", "aria-label": "Cor da \xE1rea", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
18219
+ /* @__PURE__ */ jsx(SelectContent, { children: AREA_COLORS.map((c) => /* @__PURE__ */ jsx(SelectItem, { value: c.value, children: c.label }, c.value)) })
18220
+ ] }),
18221
+ /* @__PURE__ */ jsx(IconButton, { label: "Subir", disabled: index === 0, onClick: () => moveArea(index, -1), children: /* @__PURE__ */ jsx(ArrowUp, { className: "h-4 w-4" }) }),
18222
+ /* @__PURE__ */ jsx(IconButton, { label: "Descer", disabled: index === areas.length - 1, onClick: () => moveArea(index, 1), children: /* @__PURE__ */ jsx(ArrowDown, { className: "h-4 w-4" }) }),
18223
+ /* @__PURE__ */ jsx(IconButton, { label: "Excluir \xE1rea", destructive: true, onClick: () => setAreas((list) => list.filter((_, i) => i !== index)), children: /* @__PURE__ */ jsx(Trash2, { className: "h-4 w-4" }) })
18224
+ ] }),
18225
+ /* @__PURE__ */ jsx(
18226
+ Input,
18227
+ {
18228
+ "aria-label": "Palavras da especialidade",
18229
+ value: area.keywordsText,
18230
+ placeholder: "Palavras da especialidade, separadas por v\xEDrgula",
18231
+ onChange: (e) => setAreas((list) => list.map((a, i) => i === index ? { ...a, keywordsText: e.target.value } : a))
18232
+ }
18233
+ )
18234
+ ] }, area.key)),
18235
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-xl border border-dashed border-border/70 px-3 py-2.5 text-sm text-muted-foreground", children: [
18236
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "h-2.5 w-2.5 rounded-full bg-slate-400" }),
18237
+ "Outros \u2014 fixa, recebe quem n\xE3o casar com nenhuma \xE1rea"
18238
+ ] }),
18239
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setAreas((list) => [
18240
+ ...list,
18241
+ { key: slug(`area ${list.length + 1}`, new Set(list.map((a) => a.key))), name: "", color: "slate", keywords: [], keywordsText: "" }
18242
+ ]), children: [
18243
+ /* @__PURE__ */ jsx(Plus, { className: "mr-1.5 h-4 w-4" }),
18244
+ "Nova \xE1rea"
18245
+ ] })
18246
+ ] }),
18247
+ /* @__PURE__ */ jsxs(TabsContent, { value: "people", className: "mt-0 space-y-3", children: [
18248
+ /* @__PURE__ */ jsx(NetworkNotice, {}),
18249
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Autom\xE1tico segue a especialidade do cadastro. Escolha uma \xE1rea para fixar a pessoa nela." }),
18250
+ config.professionals.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nenhum profissional com agenda nesta unidade." }) : /* @__PURE__ */ jsx("div", { className: "divide-y divide-border/60 rounded-xl border border-border/70", children: config.professionals.map((p) => /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3 px-3 py-2", children: [
18251
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate text-sm text-foreground", children: p.name }),
18252
+ /* @__PURE__ */ jsxs(
18253
+ Select,
18254
+ {
18255
+ value: pinned[p.professionalId] ?? AUTO,
18256
+ onValueChange: (value) => setPinned((m) => ({ ...m, [p.professionalId]: value === AUTO ? null : value })),
18257
+ children: [
18258
+ /* @__PURE__ */ jsx(SelectTrigger, { className: "w-52 shrink-0", "aria-label": `\xC1rea de ${p.name}`, children: /* @__PURE__ */ jsx(SelectValue, {}) }),
18259
+ /* @__PURE__ */ jsxs(SelectContent, { children: [
18260
+ /* @__PURE__ */ jsxs(SelectItem, { value: AUTO, children: [
18261
+ "Autom\xE1tico (",
18262
+ areaName(p.autoArea),
18263
+ ")"
18264
+ ] }),
18265
+ areaOptions.map((a) => /* @__PURE__ */ jsx(SelectItem, { value: a.key, children: a.name }, a.key))
18266
+ ] })
18267
+ ]
18268
+ }
18269
+ )
18270
+ ] }, p.professionalId)) })
18271
+ ] }),
18272
+ /* @__PURE__ */ jsxs(TabsContent, { value: "quick", className: "mt-0 space-y-3", children: [
18273
+ /* @__PURE__ */ jsx(NetworkNotice, {}),
18274
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Servi\xE7os marcados aqui n\xE3o tiram o profissional da vez (ex.: esmalta\xE7\xE3o). O atendimento acontece e ele continua no mesmo lugar." }),
18275
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
18276
+ /* @__PURE__ */ jsx(Input, { value: search, onChange: (e) => setSearch(e.target.value), placeholder: "Buscar servi\xE7o", "aria-label": "Buscar servi\xE7o" }),
18277
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", onClick: suggest, className: "shrink-0", children: [
18278
+ /* @__PURE__ */ jsx(Sparkles, { className: "mr-1.5 h-4 w-4" }),
18279
+ "Sugerir pelo nome"
18280
+ ] })
18281
+ ] }),
18282
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
18283
+ quick.size,
18284
+ " marcado(s)"
18285
+ ] }),
18286
+ /* @__PURE__ */ jsx("div", { className: "max-h-80 divide-y divide-border/50 overflow-y-auto rounded-xl border border-border/70", children: services.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-3 py-4 text-sm text-muted-foreground", children: "Nenhum servi\xE7o encontrado." }) : services.map((s) => /* @__PURE__ */ jsxs("label", { className: "flex cursor-pointer items-center gap-3 px-3 py-2 text-sm text-foreground", children: [
18287
+ /* @__PURE__ */ jsx(Checkbox, { checked: quick.has(s.id), onChange: (on) => setQuick((prev) => {
18288
+ const next = new Set(prev);
18289
+ if (on) next.add(s.id);
18290
+ else next.delete(s.id);
18291
+ return next;
18292
+ }) }),
18293
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: s.name })
18294
+ ] }, s.id)) })
18295
+ ] })
18296
+ ] })
18297
+ ] }),
18298
+ /* @__PURE__ */ jsxs(ModalFooter, { className: "flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end", children: [
18299
+ error && config ? /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive sm:mr-auto", children: error }) : null,
18300
+ blank && config ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground sm:mr-auto", children: "D\xEA um nome para a \xE1rea nova antes de salvar." }) : null,
18301
+ /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: onClose, disabled: saving, children: "Cancelar" }),
18302
+ /* @__PURE__ */ jsx(Button, { onClick: () => {
18303
+ void save();
18304
+ }, disabled: !config || saving || !!blank, children: saving ? "Salvando\u2026" : "Salvar" })
18305
+ ] })
18306
+ ] }) }) });
16066
18307
  }
16067
- var STATUS = {
16068
- available: { label: "Dispon\xEDvel", className: "bg-success/15 text-success" },
16069
- serving: { label: "Em atendimento", className: "bg-primary/10 text-primary" },
16070
- unavailable: { label: "Fora", className: "bg-muted text-muted-foreground" }
16071
- };
16072
- function time(iso) {
16073
- return new Date(iso).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
18308
+ function NetworkNotice() {
18309
+ return /* @__PURE__ */ jsxs("p", { className: "flex items-start gap-2 rounded-lg bg-muted/60 px-3 py-2 text-xs text-muted-foreground", children: [
18310
+ /* @__PURE__ */ jsx(Info, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }),
18311
+ "Vale para todas as unidades da rede."
18312
+ ] });
16074
18313
  }
18314
+ var LEGEND = "Cliente sem prefer\xEAncia vai para o primeiro dispon\xEDvel da \xE1rea. Quem atende sai da vez e, ao terminar, volta para o fim. Sa\xEDda curta (banheiro, almo\xE7o\u2026) guarda o lugar.";
16075
18315
  function localNow() {
16076
18316
  const now = /* @__PURE__ */ new Date();
16077
18317
  const pad = (value) => String(value).padStart(2, "0");
16078
18318
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:00`;
16079
18319
  }
16080
- function ProfessionalRoster({ professionals }) {
16081
- return /* @__PURE__ */ jsx(Card, { className: "overflow-hidden lg:sticky lg:top-4", children: /* @__PURE__ */ jsxs(CardContent, { className: "p-0", children: [
16082
- /* @__PURE__ */ jsx("div", { className: "border-b border-border/70 p-4", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
16083
- /* @__PURE__ */ jsxs("div", { children: [
16084
- /* @__PURE__ */ jsx("h2", { className: "font-semibold", children: "Profissionais hoje" }),
16085
- /* @__PURE__ */ jsxs("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [
16086
- "Nesta unidade \xB7 ",
16087
- professionals.length,
16088
- " na agenda"
18320
+ function readTvFlag() {
18321
+ return /[?&]tv=1\b/.test(window.location.hash);
18322
+ }
18323
+ function writeTvFlag(on) {
18324
+ const [path] = window.location.hash.split("?");
18325
+ window.history.replaceState(window.history.state, "", on ? `${path}?tv=1` : path);
18326
+ }
18327
+ function openBooking(unitId, professionalId) {
18328
+ window.location.hash = "/agenda";
18329
+ window.setTimeout(() => {
18330
+ window.dispatchEvent(new CustomEvent("agenda:open-booking", {
18331
+ detail: { prefill: { professionalId, locationId: unitId, startsAt: localNow() } }
18332
+ }));
18333
+ }, 150);
18334
+ }
18335
+ function groupByArea(board) {
18336
+ return board.areas.map((area) => {
18337
+ const mine = board.entries.filter((entry) => entry.area === area.key);
18338
+ return {
18339
+ area,
18340
+ queue: mine.filter((entry) => entry.status !== "serving").sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)),
18341
+ serving: mine.filter((entry) => entry.status === "serving")
18342
+ };
18343
+ });
18344
+ }
18345
+ function BoardSkeleton() {
18346
+ return /* @__PURE__ */ jsx("div", { className: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(15rem,1fr))]", children: [0, 1, 2, 3].map((i) => /* @__PURE__ */ jsxs("div", { className: "space-y-3 rounded-2xl border border-border/60 p-3", children: [
18347
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
18348
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-5 w-24" }),
18349
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-5 w-14 rounded-full" })
18350
+ ] }),
18351
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-9 w-full rounded-lg" }),
18352
+ [0, 1].map((j) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 rounded-xl border border-border/50 bg-card p-3", children: [
18353
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-3" }),
18354
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-9 w-9 rounded-full" }),
18355
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 space-y-1.5", children: [
18356
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-3/4" }),
18357
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-3 w-1/2" })
18358
+ ] })
18359
+ ] }, j))
18360
+ ] }, i)) });
18361
+ }
18362
+ function IconAction({ label, children }) {
18363
+ return /* @__PURE__ */ jsxs(Tooltip, { children: [
18364
+ /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children }),
18365
+ /* @__PURE__ */ jsx(TooltipContent, { children: label })
18366
+ ] });
18367
+ }
18368
+ function QueueCard({ entry, index, total, area, canEdit, canDelete, onAct }) {
18369
+ const tone = toneOf(area.color);
18370
+ const away = entry.status === "away";
18371
+ const isNext = !away && index === 0;
18372
+ const served = entry.servedToday;
18373
+ return /* @__PURE__ */ jsxs("div", { className: "relative overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm", children: [
18374
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("absolute inset-x-0 top-0 h-1", tone.stripe) }),
18375
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 pb-3 pt-4", children: [
18376
+ /* @__PURE__ */ jsx("span", { className: "w-4 shrink-0 text-center text-lg font-semibold tabular-nums text-muted-foreground", children: entry.rank ?? index + 1 }),
18377
+ /* @__PURE__ */ jsx(PersonAvatar, { name: entry.name, src: entry.avatarUrl, size: "md" }),
18378
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
18379
+ /* @__PURE__ */ jsx("p", { className: "break-words text-sm font-semibold leading-snug text-foreground", children: entry.name }),
18380
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: away ? /* @__PURE__ */ jsxs("span", { className: "font-medium text-destructive", children: [
18381
+ entry.awayReason ?? "Ausente",
18382
+ " \xB7 ",
18383
+ sinceLabel(entry.since)
18384
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
18385
+ isNext ? /* @__PURE__ */ jsx("span", { className: cn("font-semibold", tone.text), children: "Pr\xF3ximo \xB7 " }) : null,
18386
+ "na fila ",
18387
+ sinceLabel(entry.since) === "agora" ? "agora" : `h\xE1 ${sinceLabel(entry.since)}`
18388
+ ] }) }),
18389
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
18390
+ served,
18391
+ " ",
18392
+ served === 1 ? "atendimento" : "atendimentos",
18393
+ " da vez hoje"
16089
18394
  ] })
16090
18395
  ] }),
16091
- /* @__PURE__ */ jsx("div", { className: "flex -space-x-2", children: professionals.slice(0, 4).map((professional) => /* @__PURE__ */ jsxs(Avatar, { className: "h-7 w-7 border-2 border-card", children: [
16092
- /* @__PURE__ */ jsx(AvatarImage, { src: professional.professionalAvatarUrl ?? void 0 }),
16093
- /* @__PURE__ */ jsx(AvatarFallback, { className: "text-[9px]", children: initials(professional.professionalName) })
16094
- ] }, professional.professionalId)) })
16095
- ] }) }),
16096
- professionals.length === 0 ? /* @__PURE__ */ jsx("div", { className: "p-5 text-center text-sm text-muted-foreground", children: "Nenhum profissional na agenda desta unidade hoje." }) : /* @__PURE__ */ jsx("div", { className: "max-h-[calc(100vh-16rem)] divide-y divide-border/60 overflow-y-auto", children: professionals.map((professional) => {
16097
- const available = professional.status === "available";
16098
- const serving = professional.status === "serving";
16099
- return /* @__PURE__ */ jsxs("div", { className: "flex gap-3 p-3.5", children: [
16100
- /* @__PURE__ */ jsxs("div", { className: "relative shrink-0", children: [
16101
- /* @__PURE__ */ jsxs(Avatar, { className: "h-11 w-11", children: [
16102
- /* @__PURE__ */ jsx(AvatarImage, { src: professional.professionalAvatarUrl ?? void 0 }),
16103
- /* @__PURE__ */ jsx(AvatarFallback, { children: initials(professional.professionalName) })
16104
- ] }),
16105
- /* @__PURE__ */ jsx("span", { className: `absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-card ${available ? "bg-success" : serving ? "bg-primary" : "bg-muted-foreground/50"}` })
16106
- ] }),
16107
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
16108
- /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-2", children: [
16109
- /* @__PURE__ */ jsx("p", { className: "truncate text-sm font-medium", children: professional.professionalName }),
16110
- available && professional.position > 0 ? /* @__PURE__ */ jsxs("span", { className: "text-xs font-semibold tabular-nums text-primary", children: [
16111
- "#",
16112
- professional.position
16113
- ] }) : null
16114
- ] }),
16115
- /* @__PURE__ */ jsx("p", { className: `mt-0.5 text-xs font-medium ${available ? "text-success" : serving ? "text-primary" : "text-muted-foreground"}`, children: available ? "Dispon\xEDvel" : serving ? "Em atendimento" : "Indispon\xEDvel" }),
16116
- /* @__PURE__ */ jsxs("div", { className: "mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground", children: [
16117
- /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
16118
- /* @__PURE__ */ jsx(Clock3, { className: "h-3 w-3" }),
16119
- professional.lastCheckinAt ? `Check-in ${time(professional.lastCheckinAt)}` : "Sem check-in hoje"
18396
+ canEdit || canDelete ? /* @__PURE__ */ jsxs(Dropdown, { children: [
18397
+ /* @__PURE__ */ jsx(DropdownTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0", "aria-label": `A\xE7\xF5es de ${entry.name}`, children: /* @__PURE__ */ jsx(MoreVertical, { className: "h-4 w-4" }) }) }),
18398
+ /* @__PURE__ */ jsxs(DropdownContent, { align: "end", className: "w-56", children: [
18399
+ /* @__PURE__ */ jsx(DropdownLabel, { children: entry.name }),
18400
+ canEdit ? /* @__PURE__ */ jsxs(Fragment, { children: [
18401
+ /* @__PURE__ */ jsxs(DropdownItem, { disabled: index === 0, onSelect: () => onAct(entry, "move", { position: 1 }), children: [
18402
+ /* @__PURE__ */ jsx(ArrowUpToLine, { className: "mr-2 h-4 w-4" }),
18403
+ "Mover para o topo"
16120
18404
  ] }),
16121
- professional.appointmentCount > 0 ? /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
16122
- /* @__PURE__ */ jsx(CalendarDays, { className: "h-3 w-3" }),
16123
- professional.appointmentCount,
16124
- " ",
16125
- professional.appointmentCount === 1 ? "hor\xE1rio" : "hor\xE1rios"
16126
- ] }) : null
16127
- ] })
18405
+ /* @__PURE__ */ jsxs(DropdownItem, { disabled: index === 0, onSelect: () => onAct(entry, "move", { position: index }), children: [
18406
+ /* @__PURE__ */ jsx(ArrowUp, { className: "mr-2 h-4 w-4" }),
18407
+ "Subir uma posi\xE7\xE3o"
18408
+ ] }),
18409
+ /* @__PURE__ */ jsxs(DropdownItem, { disabled: index === total - 1, onSelect: () => onAct(entry, "move", { position: index + 2 }), children: [
18410
+ /* @__PURE__ */ jsx(ArrowDown, { className: "mr-2 h-4 w-4" }),
18411
+ "Descer uma posi\xE7\xE3o"
18412
+ ] }),
18413
+ /* @__PURE__ */ jsx(DropdownSeparator, {}),
18414
+ away ? /* @__PURE__ */ jsxs(DropdownItem, { onSelect: () => onAct(entry, "back"), children: [
18415
+ /* @__PURE__ */ jsx(Undo2, { className: "mr-2 h-4 w-4" }),
18416
+ "Voltou"
18417
+ ] }) : /* @__PURE__ */ jsxs(DropdownSub, { children: [
18418
+ /* @__PURE__ */ jsxs(DropdownSubTrigger, { children: [
18419
+ /* @__PURE__ */ jsx(Coffee, { className: "mr-2 h-4 w-4" }),
18420
+ "Saiu (guarda o lugar)"
18421
+ ] }),
18422
+ /* @__PURE__ */ jsx(DropdownSubContent, { children: AWAY_REASONS.map((reason) => /* @__PURE__ */ jsx(DropdownItem, { onSelect: () => onAct(entry, "away", { reason }), children: reason }, reason)) })
18423
+ ] }),
18424
+ /* @__PURE__ */ jsxs(DropdownItem, { onSelect: () => onAct(entry, "start"), children: [
18425
+ /* @__PURE__ */ jsx(Play, { className: "mr-2 h-4 w-4" }),
18426
+ "Come\xE7ou um atendimento"
18427
+ ] })
18428
+ ] }) : null,
18429
+ canDelete ? /* @__PURE__ */ jsxs(Fragment, { children: [
18430
+ /* @__PURE__ */ jsx(DropdownSeparator, {}),
18431
+ /* @__PURE__ */ jsxs(DropdownItem, { className: "text-destructive focus:text-destructive", onSelect: () => onAct(entry, "leave"), children: [
18432
+ /* @__PURE__ */ jsx(LogOut, { className: "mr-2 h-4 w-4" }),
18433
+ "Tirar da fila"
18434
+ ] })
18435
+ ] }) : null
16128
18436
  ] })
16129
- ] }, professional.professionalId);
16130
- }) })
16131
- ] }) });
18437
+ ] }) : null
18438
+ ] })
18439
+ ] });
16132
18440
  }
16133
- function QueueSkeleton() {
16134
- return /* @__PURE__ */ jsx("div", { className: "grid gap-3", children: [0, 1, 2].map((i) => /* @__PURE__ */ jsx(Skeleton, { className: "h-20 rounded-xl" }, i)) });
18441
+ function AreaColumn({ group, canEdit, canDelete, onAct, onCall }) {
18442
+ const tone = toneOf(group.area.color);
18443
+ const firstAvailable = group.queue.find((entry) => entry.status === "available");
18444
+ const inTurn = group.queue.filter((entry) => entry.status === "available").length;
18445
+ return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-col rounded-2xl border", tone.column), "aria-label": `\xC1rea ${group.area.name}`, children: [
18446
+ /* @__PURE__ */ jsxs("div", { className: "space-y-3 border-b border-border/40 p-3", children: [
18447
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
18448
+ /* @__PURE__ */ jsxs("h2", { className: "flex min-w-0 items-center gap-2 text-base font-semibold text-foreground", children: [
18449
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("h-2.5 w-2.5 shrink-0 rounded-full", tone.dot) }),
18450
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: group.area.name })
18451
+ ] }),
18452
+ /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "shrink-0 tabular-nums", children: [
18453
+ inTurn,
18454
+ " na vez"
18455
+ ] })
18456
+ ] }),
18457
+ canEdit ? /* @__PURE__ */ jsxs(
18458
+ "button",
18459
+ {
18460
+ type: "button",
18461
+ disabled: !firstAvailable,
18462
+ onClick: () => onCall(group),
18463
+ className: cn(
18464
+ "inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-45",
18465
+ tone.button
18466
+ ),
18467
+ children: [
18468
+ /* @__PURE__ */ jsx(BellRing, { className: "h-4 w-4 shrink-0" }),
18469
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: firstAvailable ? `Chamar o pr\xF3ximo: ${firstName(firstAvailable.name)}` : "Ningu\xE9m dispon\xEDvel" })
18470
+ ]
18471
+ }
18472
+ ) : null
18473
+ ] }),
18474
+ /* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col gap-2 p-2.5", children: group.queue.length === 0 ? /* @__PURE__ */ jsx("p", { className: "rounded-xl border border-dashed border-border/70 px-3 py-4 text-center text-sm text-muted-foreground", children: "Ningu\xE9m na fila desta \xE1rea." }) : group.queue.map((entry, index) => /* @__PURE__ */ jsx(
18475
+ QueueCard,
18476
+ {
18477
+ entry,
18478
+ index,
18479
+ total: group.queue.length,
18480
+ area: group.area,
18481
+ canEdit,
18482
+ canDelete,
18483
+ onAct
18484
+ },
18485
+ entry.professionalId
18486
+ )) }),
18487
+ group.serving.length > 0 ? /* @__PURE__ */ jsxs("div", { className: "space-y-1.5 border-t border-border/40 p-2.5", children: [
18488
+ /* @__PURE__ */ jsx("p", { className: "px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: "Em atendimento" }),
18489
+ group.serving.map((entry) => /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 rounded-lg bg-card/80 px-2.5 py-2", children: [
18490
+ /* @__PURE__ */ jsxs("p", { className: "min-w-0 truncate text-sm text-foreground", children: [
18491
+ /* @__PURE__ */ jsx("span", { className: "font-semibold", children: firstName(entry.name) }),
18492
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
18493
+ " \xB7 ",
18494
+ sinceLabel(entry.since)
18495
+ ] })
18496
+ ] }),
18497
+ canEdit ? /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", className: "h-8 shrink-0", onClick: () => onAct(entry, "finish"), children: [
18498
+ /* @__PURE__ */ jsx(CheckCircle2, { className: "mr-1.5 h-3.5 w-3.5" }),
18499
+ "Terminou"
18500
+ ] }) : null
18501
+ ] }, entry.professionalId))
18502
+ ] }) : null
18503
+ ] });
16135
18504
  }
16136
- function EmptyUnit() {
16137
- return /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "flex min-h-64 flex-col items-center justify-center gap-3 text-center", children: [
16138
- /* @__PURE__ */ jsx(MapPin, { className: "h-9 w-9 text-muted-foreground" }),
16139
- /* @__PURE__ */ jsxs("div", { children: [
16140
- /* @__PURE__ */ jsx("p", { className: "font-medium", children: "Escolha uma unidade" }),
16141
- /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: "A lista da vez sempre pertence a uma unidade." })
16142
- ] })
16143
- ] }) });
18505
+ function OutOfQueue({ board, canCreate, onJoin }) {
18506
+ const groups = board.areas.map((area) => ({ area, members: board.roster.filter((member) => member.area === area.key) })).filter((group) => group.members.length > 0);
18507
+ return /* @__PURE__ */ jsxs("section", { className: "space-y-3", children: [
18508
+ /* @__PURE__ */ jsx("h2", { className: "text-base font-semibold text-foreground", children: "Fora da fila" }),
18509
+ groups.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Toda a equipe da unidade est\xE1 na fila." }) : /* @__PURE__ */ jsx("div", { className: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(20rem,1fr))]", children: groups.map(({ area, members }) => /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "space-y-2 p-3", children: [
18510
+ /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-2 text-sm font-semibold text-foreground", children: [
18511
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("h-2.5 w-2.5 rounded-full", toneOf(area.color).dot) }),
18512
+ area.name
18513
+ ] }),
18514
+ members.map((member) => /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 py-1.5", children: [
18515
+ /* @__PURE__ */ jsx(PersonAvatar, { name: member.name, src: member.avatarUrl, size: "sm" }),
18516
+ /* @__PURE__ */ jsxs("div", { className: "min-w-[9rem] flex-1", children: [
18517
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium leading-snug text-foreground", children: member.name }),
18518
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: member.appointmentsToday === 0 ? "Sem agenda hoje" : `${member.appointmentsToday} ${member.appointmentsToday === 1 ? "hor\xE1rio" : "hor\xE1rios"} hoje${member.nextAppointmentAt ? ` \xB7 pr\xF3ximo ${clock(member.nextAppointmentAt)}` : ""}` })
18519
+ ] }),
18520
+ canCreate ? /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", className: "h-8 shrink-0", onClick: () => onJoin(member), children: [
18521
+ /* @__PURE__ */ jsx(UserPlus, { className: "mr-1.5 h-3.5 w-3.5" }),
18522
+ "Colocar na fila"
18523
+ ] }) : null
18524
+ ] }, member.professionalId))
18525
+ ] }) }, area.key)) })
18526
+ ] });
18527
+ }
18528
+ function TodayTrail({ board }) {
18529
+ const [open, setOpen] = React23.useState(false);
18530
+ return /* @__PURE__ */ jsxs("section", { className: "space-y-2", children: [
18531
+ /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", className: "-ml-2 h-8", onClick: () => setOpen((value) => !value), "aria-expanded": open, children: [
18532
+ /* @__PURE__ */ jsx(History, { className: "mr-2 h-4 w-4" }),
18533
+ "Movimentos de hoje (",
18534
+ board.events.length,
18535
+ ")"
18536
+ ] }),
18537
+ open ? board.events.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nenhum movimento hoje." }) : /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "divide-y divide-border/60 p-0", children: board.events.map((event) => /* @__PURE__ */ jsxs("div", { className: "flex gap-3 px-4 py-2 text-sm", children: [
18538
+ /* @__PURE__ */ jsx("span", { className: "w-11 shrink-0 tabular-nums text-muted-foreground", children: clock(event.createdAt) }),
18539
+ /* @__PURE__ */ jsxs("p", { className: "min-w-0 text-foreground", children: [
18540
+ /* @__PURE__ */ jsx("span", { className: "font-semibold", children: event.name }),
18541
+ " ",
18542
+ eventPhrase(event)
18543
+ ] })
18544
+ ] }, event.id)) }) }) : null
18545
+ ] });
18546
+ }
18547
+ function TvBoard({ groups, onFullscreen }) {
18548
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
18549
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
18550
+ /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold text-foreground", children: "Lista da vez" }),
18551
+ /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: onFullscreen, children: [
18552
+ /* @__PURE__ */ jsx(Maximize2, { className: "mr-2 h-4 w-4" }),
18553
+ "Tela cheia"
18554
+ ] })
18555
+ ] }),
18556
+ /* @__PURE__ */ jsx("div", { className: "grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(14rem,1fr))]", children: groups.map((group) => {
18557
+ const tone = toneOf(group.area.color);
18558
+ const next = group.queue.find((entry) => entry.status === "available");
18559
+ return /* @__PURE__ */ jsxs("section", { className: cn("min-w-0 rounded-2xl border p-5", tone.column), children: [
18560
+ /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-2 text-lg font-semibold text-foreground", children: [
18561
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("h-3 w-3 rounded-full", tone.dot) }),
18562
+ group.area.name
18563
+ ] }),
18564
+ /* @__PURE__ */ jsx("p", { className: "mt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: "Pr\xF3ximo da vez" }),
18565
+ /* @__PURE__ */ jsx("p", { className: "mt-1 break-words text-4xl font-bold leading-tight text-foreground", children: next ? next.name : "\u2014" }),
18566
+ /* @__PURE__ */ jsx("ol", { className: "mt-4 space-y-1.5", children: group.queue.map((entry, index) => /* @__PURE__ */ jsxs("li", { className: cn("flex items-baseline gap-3 text-2xl", entry.status === "away" ? "text-muted-foreground line-through decoration-1" : "text-foreground"), children: [
18567
+ /* @__PURE__ */ jsx("span", { className: "w-6 shrink-0 text-right tabular-nums text-muted-foreground", children: index + 1 }),
18568
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: entry.name })
18569
+ ] }, entry.professionalId)) })
18570
+ ] }, group.area.key);
18571
+ }) })
18572
+ ] });
16144
18573
  }
16145
- function TurnQueuePage({ provider: provider2, agendaProvider, title, subtitle }) {
18574
+ function TurnQueuePage({ provider: provider2, title }) {
16146
18575
  const unitId = useActiveUnitId();
16147
- const [agendaProfessionals, setAgendaProfessionals] = React23.useState([]);
16148
- const [snapshot, setSnapshot] = React23.useState(null);
16149
- const [loading, setLoading] = React23.useState(false);
18576
+ const can = usePermissionOptional();
18577
+ const canEdit = can("agenda.turn_queue", "edit");
18578
+ const canCreate = can("agenda.turn_queue", "create");
18579
+ const canDelete = can("agenda.turn_queue", "delete");
18580
+ const [board, setBoard] = React23.useState(null);
16150
18581
  const [error, setError] = React23.useState(null);
18582
+ const [tv, setTv] = React23.useState(readTvFlag);
18583
+ const [configOpen, setConfigOpen] = React23.useState(false);
18584
+ const [skipAsk, setSkipAsk] = React23.useState(null);
16151
18585
  const [, tick] = React23.useState(0);
18586
+ const tvRef = React23.useRef(null);
16152
18587
  const load = React23.useCallback(async () => {
16153
18588
  if (!unitId) {
16154
- setSnapshot(null);
18589
+ setBoard(null);
16155
18590
  return;
16156
18591
  }
16157
- setLoading(true);
16158
18592
  try {
16159
- setSnapshot(await provider2.getSnapshot(unitId));
18593
+ const next = await provider2.getBoard(unitId);
18594
+ setBoard(next);
16160
18595
  setError(null);
18596
+ const leader = next.entries.filter((e) => e.status === "available").sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))[0];
18597
+ publishTurnQueueLeader(unitId, leader?.professionalId ?? null);
16161
18598
  } catch (cause) {
16162
- setError(String(cause?.message ?? "N\xE3o foi poss\xEDvel carregar a fila."));
16163
- } finally {
16164
- setLoading(false);
18599
+ setError(turnErrorMessage(cause));
16165
18600
  }
16166
18601
  }, [provider2, unitId]);
16167
18602
  React23.useEffect(() => {
18603
+ setBoard(null);
16168
18604
  void load();
16169
18605
  }, [load]);
16170
- React23.useEffect(() => {
16171
- if (!unitId) {
16172
- setAgendaProfessionals([]);
16173
- return;
16174
- }
16175
- let active = true;
16176
- void agendaProvider.getProfessionals({ unitId }).then((rows2) => {
16177
- if (active) setAgendaProfessionals(rows2);
16178
- }).catch(() => {
16179
- if (active) setAgendaProfessionals([]);
16180
- });
16181
- return () => {
16182
- active = false;
16183
- };
16184
- }, [agendaProvider, unitId]);
16185
18606
  React23.useEffect(() => provider2.subscribe(() => {
16186
18607
  void load();
16187
18608
  }), [provider2, load]);
16188
18609
  React23.useEffect(() => {
16189
- const timer = window.setInterval(() => tick((value) => value + 1), 6e4);
18610
+ const timer = window.setInterval(() => tick((value) => value + 1), 3e4);
16190
18611
  return () => window.clearInterval(timer);
16191
18612
  }, []);
16192
- const rows = React23.useMemo(() => [...snapshot?.entries ?? []].sort((a, b) => {
16193
- const rank = { available: 0, serving: 1, unavailable: 2 };
16194
- return rank[a.status] - rank[b.status] || a.position - b.position;
16195
- }), [snapshot]);
16196
- const queueRows = rows.filter((row) => row.status !== "unavailable");
16197
- const professionals = React23.useMemo(() => {
16198
- const byId = /* @__PURE__ */ new Map();
16199
- for (const professional of agendaProfessionals) {
16200
- byId.set(professional.id, {
16201
- professionalId: professional.id,
16202
- professionalName: professional.name,
16203
- professionalAvatarUrl: professional.avatarUrl,
16204
- status: "unavailable",
16205
- position: 0,
16206
- lastCheckinAt: null,
16207
- isScheduled: true,
16208
- appointmentCount: 0,
16209
- nextAppointmentAt: null
16210
- });
18613
+ const groups = React23.useMemo(() => board ? groupByArea(board) : [], [board]);
18614
+ const act = React23.useCallback(async (entry, action, extra) => {
18615
+ if (!unitId) return;
18616
+ try {
18617
+ const result = await provider2.act({ unitId, professionalId: entry.professionalId, action, ...extra });
18618
+ const who = entry.name;
18619
+ if (action === "join") toast.success(`${who} entrou no fim da fila.`);
18620
+ else if (action === "leave") toast.success(`${who} saiu da fila.`);
18621
+ else if (action === "away") toast.success(`${who} saiu (${extra?.reason}). O lugar fica guardado.`);
18622
+ else if (action === "back") toast.success(`${who} voltou.`);
18623
+ else if (action === "start") toast.success(`${who} come\xE7ou um atendimento.`);
18624
+ else if (action === "finish") toast.success(`${who} voltou para o fim da fila.`);
18625
+ else if (action === "call") {
18626
+ toast.success(`${who} foi chamado(a).`);
18627
+ if (result.notified) {
18628
+ toast.success(`${who} foi avisado(a) no sino. Marque o encaixe na agenda com o profissional.`, {
18629
+ action: { label: "Abrir agenda", onClick: () => openBooking(unitId, entry.professionalId) }
18630
+ });
18631
+ }
18632
+ }
18633
+ await load();
18634
+ } catch (cause) {
18635
+ toast.error(turnErrorMessage(cause));
16211
18636
  }
16212
- for (const professional of snapshot?.professionals ?? []) {
16213
- byId.set(professional.professionalId, professional);
18637
+ }, [load, provider2, unitId]);
18638
+ const call = React23.useCallback((group) => {
18639
+ const next = group.queue.find((entry) => entry.status === "available");
18640
+ if (!next) return;
18641
+ const away = group.queue.slice(0, group.queue.indexOf(next)).find((entry) => entry.status === "away");
18642
+ if (away) {
18643
+ setSkipAsk({ away, next });
18644
+ return;
16214
18645
  }
16215
- const rank = { available: 0, serving: 1, unavailable: 2 };
16216
- return [...byId.values()].sort((a, b) => rank[a.status] - rank[b.status] || a.position - b.position || a.professionalName.localeCompare(b.professionalName));
16217
- }, [agendaProfessionals, snapshot]);
16218
- const available = professionals.filter((row) => row.status === "available").length;
16219
- const serving = professionals.filter((row) => row.status === "serving").length;
16220
- const addWalkIn = React23.useCallback((professionalId) => {
18646
+ void act(next, "call");
18647
+ }, [act]);
18648
+ const confirmSkip = async () => {
18649
+ if (!skipAsk) return;
18650
+ const { away, next } = skipAsk;
18651
+ setSkipAsk(null);
16221
18652
  if (!unitId) return;
16222
- window.location.hash = "/agenda";
16223
- window.setTimeout(() => {
16224
- window.dispatchEvent(new CustomEvent("agenda:open-booking", {
16225
- detail: {
16226
- prefill: {
16227
- professionalId,
16228
- locationId: unitId,
16229
- startsAt: localNow()
16230
- }
18653
+ try {
18654
+ await provider2.act({ unitId, professionalId: away.professionalId, action: "skip", reason: away.awayReason ?? void 0 });
18655
+ } catch (cause) {
18656
+ toast.error(turnErrorMessage(cause));
18657
+ return;
18658
+ }
18659
+ await act(next, "call");
18660
+ };
18661
+ const toggleTv = () => {
18662
+ setTv((value) => {
18663
+ writeTvFlag(!value);
18664
+ return !value;
18665
+ });
18666
+ };
18667
+ const header = /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2 lg:justify-end", children: [
18668
+ /* @__PURE__ */ jsxs(Badge, { variant: "outline", className: "h-8 gap-1.5 rounded-full border-success/30 px-3 text-success", children: [
18669
+ /* @__PURE__ */ jsx(Radio, { className: "h-3.5 w-3.5" }),
18670
+ "Ao vivo"
18671
+ ] }),
18672
+ /* @__PURE__ */ jsxs(Button, { variant: tv ? "secondary" : "outline", size: "sm", onClick: toggleTv, children: [
18673
+ /* @__PURE__ */ jsx(Monitor, { className: "mr-2 h-4 w-4" }),
18674
+ tv ? "Quadro" : "Modo TV"
18675
+ ] }),
18676
+ can("agenda.turn_queue", "read") ? /* @__PURE__ */ jsx(IconAction, { label: "Relat\xF3rio da lista da vez", children: /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", "aria-label": "Relat\xF3rio da lista da vez", onClick: () => adminNavigateTo("/agenda/turn-queue/report"), children: [
18677
+ /* @__PURE__ */ jsx(BarChart3, { className: "h-4 w-4 sm:mr-2" }),
18678
+ /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "Relat\xF3rio" })
18679
+ ] }) }) : null,
18680
+ canEdit ? /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setConfigOpen(true), children: [
18681
+ /* @__PURE__ */ jsx(SlidersHorizontal, { className: "mr-2 h-4 w-4" }),
18682
+ "Configurar"
18683
+ ] }) : null
18684
+ ] });
18685
+ let body;
18686
+ if (!unitId) {
18687
+ body = /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "flex min-h-64 flex-col items-center justify-center gap-3 text-center", children: [
18688
+ /* @__PURE__ */ jsx(MapPin, { className: "h-9 w-9 text-muted-foreground" }),
18689
+ /* @__PURE__ */ jsxs("div", { children: [
18690
+ /* @__PURE__ */ jsx("p", { className: "font-medium text-foreground", children: "Escolha uma unidade" }),
18691
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: "A lista da vez sempre pertence a uma unidade." })
18692
+ ] })
18693
+ ] }) });
18694
+ } else if (error && !board) {
18695
+ body = /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-6 text-sm text-destructive", children: error }) });
18696
+ } else if (!board) {
18697
+ body = /* @__PURE__ */ jsx(BoardSkeleton, {});
18698
+ } else if (!board.enabled) {
18699
+ body = /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "flex min-h-52 flex-col items-center justify-center gap-3 text-center", children: [
18700
+ /* @__PURE__ */ jsx("p", { className: "font-medium text-foreground", children: "A lista da vez est\xE1 desligada nesta unidade" }),
18701
+ /* @__PURE__ */ jsx("p", { className: "max-w-md text-sm text-muted-foreground", children: "Ligue no cadastro da unidade, na aba Lista da vez, na unidade que vai usar a fila." }),
18702
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => adminNavigateTo("/settings/units"), children: "Abrir unidades" })
18703
+ ] }) });
18704
+ } else if (tv) {
18705
+ body = /* @__PURE__ */ jsx("div", { ref: tvRef, className: "rounded-2xl bg-background p-1 [&:fullscreen]:overflow-auto [&:fullscreen]:p-8", children: /* @__PURE__ */ jsx(TvBoard, { groups, onFullscreen: () => {
18706
+ void tvRef.current?.requestFullscreen?.();
18707
+ } }) });
18708
+ } else {
18709
+ body = /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
18710
+ board.requireLocation && !board.locationConfigured ? /* @__PURE__ */ jsxs("div", { className: "rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-sm text-foreground", children: [
18711
+ /* @__PURE__ */ jsx("p", { className: "font-medium", children: "Localiza\xE7\xE3o da loja pendente" }),
18712
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: 'O "Cheguei" exige estar na loja, mas a unidade ainda n\xE3o tem endere\xE7o com coordenada.' })
18713
+ ] }) : null,
18714
+ /* @__PURE__ */ jsx("div", { className: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(15rem,1fr))]", children: groups.map((group) => /* @__PURE__ */ jsx(
18715
+ AreaColumn,
18716
+ {
18717
+ group,
18718
+ canEdit,
18719
+ canDelete,
18720
+ onAct: (entry, action, extra) => {
18721
+ void act(entry, action, extra);
18722
+ },
18723
+ onCall: call
18724
+ },
18725
+ group.area.key
18726
+ )) }),
18727
+ /* @__PURE__ */ jsx(OutOfQueue, { board, canCreate, onJoin: (member) => {
18728
+ void act(member, "join");
18729
+ } }),
18730
+ /* @__PURE__ */ jsx(TodayTrail, { board })
18731
+ ] });
18732
+ }
18733
+ return /* @__PURE__ */ jsxs(ModulePage, { title, nav: [], navVariant: "sidebar", showHeader: false, children: [
18734
+ /* @__PURE__ */ jsx(TooltipProvider, { delayDuration: 200, children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-[96rem] space-y-4", children: [
18735
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between", children: [
18736
+ /* @__PURE__ */ jsx("p", { className: "max-w-3xl text-xs leading-relaxed text-muted-foreground", children: LEGEND }),
18737
+ header
18738
+ ] }),
18739
+ body
18740
+ ] }) }),
18741
+ /* @__PURE__ */ jsx(Modal, { open: !!skipAsk, onOpenChange: (open) => {
18742
+ if (!open) setSkipAsk(null);
18743
+ }, children: /* @__PURE__ */ jsxs(ModalContent, { size: "md", children: [
18744
+ /* @__PURE__ */ jsxs(ModalHeader, { children: [
18745
+ /* @__PURE__ */ jsx(ModalTitle, { children: "Quem est\xE1 na vez saiu" }),
18746
+ skipAsk ? /* @__PURE__ */ jsxs(ModalDescription, { children: [
18747
+ skipAsk.away.name,
18748
+ " (",
18749
+ skipAsk.away.awayReason ?? "Ausente",
18750
+ ", ",
18751
+ sinceLabel(skipAsk.away.since),
18752
+ ") est\xE1 na frente.",
18753
+ " ",
18754
+ "Pedir para o cliente esperar guarda a vez; chamar ",
18755
+ firstName(skipAsk.next.name),
18756
+ " registra o pulo."
18757
+ ] }) : null
18758
+ ] }),
18759
+ /* @__PURE__ */ jsx(ModalBody, { className: "hidden" }),
18760
+ /* @__PURE__ */ jsxs(ModalFooter, { children: [
18761
+ /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => setSkipAsk(null), children: "Esperar" }),
18762
+ /* @__PURE__ */ jsx(Button, { onClick: () => {
18763
+ void confirmSkip();
18764
+ }, children: skipAsk ? `Chamar ${firstName(skipAsk.next.name)}` : "Chamar" })
18765
+ ] })
18766
+ ] }) }),
18767
+ configOpen && unitId ? /* @__PURE__ */ jsx(
18768
+ TurnQueueConfigDialog,
18769
+ {
18770
+ provider: provider2,
18771
+ unitId,
18772
+ onClose: () => setConfigOpen(false),
18773
+ onSaved: () => {
18774
+ setConfigOpen(false);
18775
+ void load();
16231
18776
  }
16232
- }));
16233
- }, 150);
16234
- }, [unitId]);
16235
- return /* @__PURE__ */ jsx(ModulePage, { title, subtitle, nav: [], navVariant: "sidebar", children: !unitId ? /* @__PURE__ */ jsx(EmptyUnit, {}) : /* @__PURE__ */ jsxs("div", { className: `mx-auto grid w-full gap-4 lg:items-start ${professionals.length > 0 ? "max-w-7xl lg:grid-cols-[19rem_minmax(0,1fr)]" : "max-w-5xl"}`, children: [
16236
- professionals.length > 0 ? /* @__PURE__ */ jsx("aside", { className: "min-w-0", children: /* @__PURE__ */ jsx(ProfessionalRoster, { professionals }) }) : null,
16237
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-4", children: [
16238
- /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-3 sm:grid-cols-3", children: [
16239
- /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "p-4", children: [
16240
- /* @__PURE__ */ jsx(Users, { className: "mb-2 h-4 w-4 text-muted-foreground" }),
16241
- /* @__PURE__ */ jsx("p", { className: "text-2xl font-semibold tabular-nums", children: available }),
16242
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "dispon\xEDveis" })
16243
- ] }) }),
16244
- /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "p-4", children: [
16245
- /* @__PURE__ */ jsx(Activity, { className: "mb-2 h-4 w-4 text-muted-foreground" }),
16246
- /* @__PURE__ */ jsx("p", { className: "text-2xl font-semibold tabular-nums", children: serving }),
16247
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "em atendimento" })
16248
- ] }) }),
16249
- /* @__PURE__ */ jsx(Card, { className: "col-span-2 sm:col-span-1", children: /* @__PURE__ */ jsxs(CardContent, { className: "flex h-full items-center justify-between p-4", children: [
16250
- /* @__PURE__ */ jsxs("div", { children: [
16251
- /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: "Atualiza\xE7\xE3o ao vivo" }),
16252
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Todos veem a mesma ordem" })
16253
- ] }),
16254
- /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", onClick: () => {
16255
- void load();
16256
- }, "aria-label": "Atualizar", children: /* @__PURE__ */ jsx(RefreshCw, { className: "h-4 w-4" }) })
16257
- ] }) })
18777
+ }
18778
+ ) : null
18779
+ ] });
18780
+ }
18781
+ function ymd(d) {
18782
+ const pad = (n) => String(n).padStart(2, "0");
18783
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
18784
+ }
18785
+ var COLUMNS = [
18786
+ { key: "entradas", label: "Entradas na fila" },
18787
+ { key: "atendimentos", label: "Atendimentos da vez" },
18788
+ { key: "servicosRapidos", label: "Servi\xE7os r\xE1pidos" },
18789
+ { key: "chamados", label: "Chamadas" },
18790
+ { key: "pulos", label: "Pulos" },
18791
+ { key: "ausencias", label: "Aus\xEAncias" },
18792
+ { key: "minutosAusente", label: "Minutos ausente" }
18793
+ ];
18794
+ function Kpi({ label, value }) {
18795
+ return /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "p-4", children: [
18796
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: label }),
18797
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-2xl font-semibold tabular-nums text-foreground", children: value })
18798
+ ] }) });
18799
+ }
18800
+ function TurnQueueReportPage({ provider: provider2 }) {
18801
+ const activeUnit = useActiveUnitId();
18802
+ const today = /* @__PURE__ */ new Date();
18803
+ const [from, setFrom] = React23.useState(ymd(new Date(today.getFullYear(), today.getMonth(), 1)));
18804
+ const [to, setTo] = React23.useState(ymd(new Date(today.getFullYear(), today.getMonth() + 1, 0)));
18805
+ const [units, setUnits] = React23.useState([]);
18806
+ const [selected, setSelected] = React23.useState(activeUnit ? [activeUnit] : []);
18807
+ const [rows, setRows] = React23.useState(null);
18808
+ const [error, setError] = React23.useState(null);
18809
+ React23.useEffect(() => {
18810
+ const db = getSupabaseClientOptional();
18811
+ void db?.from("v_units").select("id, name").eq("is_active", true).order("name").then(({ data }) => setUnits(data ?? []));
18812
+ }, []);
18813
+ React23.useEffect(() => {
18814
+ let alive = true;
18815
+ setRows(null);
18816
+ provider2.getReport({ unitIds: selected.length ? selected : null, from, to }).then((next) => {
18817
+ if (alive) {
18818
+ setRows(next);
18819
+ setError(null);
18820
+ }
18821
+ }).catch((cause) => {
18822
+ if (alive) setError(turnErrorMessage(cause));
18823
+ });
18824
+ return () => {
18825
+ alive = false;
18826
+ };
18827
+ }, [provider2, selected, from, to]);
18828
+ const served = rows?.filter((r) => r.atendimentos > 0) ?? [];
18829
+ const total = rows?.reduce((s, r) => s + r.atendimentos, 0) ?? 0;
18830
+ const spread = rows && rows.length ? Math.max(...rows.map((r) => r.atendimentos)) - Math.min(...rows.map((r) => r.atendimentos)) : 0;
18831
+ const skips = rows?.reduce((s, r) => s + r.pulos, 0) ?? 0;
18832
+ const peak = Math.max(1, ...(rows ?? []).map((r) => r.atendimentos));
18833
+ const exportXlsx = async () => {
18834
+ if (!rows?.length) return;
18835
+ try {
18836
+ const report = await createXlsxReport({ title: "Lista da vez", subtitle: `De ${from} at\xE9 ${to}`, landscape: true });
18837
+ const sheet = report.sheet("Lista da vez", [
18838
+ { header: "Profissional", width: 28 },
18839
+ { header: "Unidade", width: 24 },
18840
+ ...COLUMNS.map((c) => ({ header: c.label, type: "number", width: 14 }))
18841
+ ]);
18842
+ sheet.header();
18843
+ sheet.table(rows.map((r) => [r.professionalName, r.unitName, ...COLUMNS.map((c) => Number(r[c.key]))]), { filter: true });
18844
+ await report.download(`lista-da-vez-${from}-${to}`);
18845
+ } catch (cause) {
18846
+ toast.error(String(cause?.message ?? "N\xE3o foi poss\xEDvel exportar."));
18847
+ }
18848
+ };
18849
+ return /* @__PURE__ */ jsx(ModulePage, { title: "Relat\xF3rio da lista da vez", nav: [], navVariant: "sidebar", showHeader: false, children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-6xl space-y-4", children: [
18850
+ /* @__PURE__ */ jsx(
18851
+ SubpageHeader,
18852
+ {
18853
+ title: "Relat\xF3rio da lista da vez",
18854
+ description: "Por profissional: entradas na fila, atendimentos da vez, servi\xE7os r\xE1pidos, chamadas, pulos e aus\xEAncias no per\xEDodo \u2014 a justi\xE7a da fila.",
18855
+ parentLabel: "Lista da vez",
18856
+ onBack: () => adminNavigateTo("/agenda/turn-queue"),
18857
+ actions: /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", disabled: !rows?.length, onClick: () => {
18858
+ void exportXlsx();
18859
+ }, children: [
18860
+ /* @__PURE__ */ jsx(Download, { className: "mr-2 h-4 w-4" }),
18861
+ "Exportar"
18862
+ ] })
18863
+ }
18864
+ ),
18865
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "grid gap-3 p-4 sm:grid-cols-[repeat(2,minmax(0,11rem))_minmax(0,1fr)]", children: [
18866
+ /* @__PURE__ */ jsxs("div", { children: [
18867
+ /* @__PURE__ */ jsx("label", { className: "mb-1 block text-xs font-medium text-muted-foreground", children: "De" }),
18868
+ /* @__PURE__ */ jsx(DatePicker, { value: from, onChange: setFrom })
16258
18869
  ] }),
16259
- loading && !snapshot ? /* @__PURE__ */ jsx(QueueSkeleton, {}) : error ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-6 text-sm text-destructive", children: error }) }) : queueRows.length === 0 ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "flex min-h-52 flex-col items-center justify-center text-center", children: [
16260
- /* @__PURE__ */ jsx(Users, { className: "mb-3 h-9 w-9 text-muted-foreground" }),
16261
- /* @__PURE__ */ jsx("p", { className: "font-medium", children: "Ningu\xE9m dispon\xEDvel na vez" }),
16262
- /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: professionals.length > 0 ? "A equipe prevista para hoje continua vis\xEDvel ao lado, mesmo sem check-in." : "Nenhum profissional est\xE1 previsto para esta unidade hoje." })
16263
- ] }) }) : /* @__PURE__ */ jsx("div", { className: "grid gap-2", children: queueRows.map((row) => {
16264
- const state = STATUS[row.status];
16265
- const queuePosition = row.status === "available" ? queueRows.filter((candidate) => candidate.status === "available").findIndex((candidate) => candidate.id === row.id) + 1 : null;
16266
- const isNext = row.status === "available" && queuePosition === 1;
16267
- return /* @__PURE__ */ jsx(Card, { className: isNext ? "overflow-hidden border-black bg-zinc-950 text-white shadow-lg ring-1 ring-black/15" : "", children: /* @__PURE__ */ jsxs(CardContent, { className: "flex items-center gap-3 p-3 sm:p-4", children: [
16268
- /* @__PURE__ */ jsx("div", { className: `flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-semibold tabular-nums ${isNext ? "border border-white/50 bg-black text-white shadow-sm" : "bg-muted"}`, children: queuePosition ?? "\u2014" }),
16269
- /* @__PURE__ */ jsxs(Avatar, { className: `h-10 w-10 ${isNext ? "border border-white/50 bg-black shadow-sm" : ""}`, children: [
16270
- /* @__PURE__ */ jsx(AvatarImage, { src: row.professionalAvatarUrl ?? void 0 }),
16271
- /* @__PURE__ */ jsx(AvatarFallback, { className: isNext ? "bg-black text-white" : void 0, children: initials(row.professionalName) })
16272
- ] }),
16273
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
16274
- /* @__PURE__ */ jsx("p", { className: "truncate font-medium", children: row.professionalName }),
16275
- /* @__PURE__ */ jsxs("p", { className: `mt-0.5 flex items-center gap-1 text-xs ${isNext ? "text-background/70" : "text-muted-foreground"}`, children: [
16276
- /* @__PURE__ */ jsx(Clock3, { className: "h-3 w-3" }),
16277
- isNext ? `Da vez \xB7 ${elapsed(row.stateChangedAt)}` : elapsed(row.stateChangedAt)
16278
- ] })
16279
- ] }),
16280
- /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [
16281
- /* @__PURE__ */ jsx(Badge, { className: isNext ? "border border-white/25 bg-white/10 text-white hover:bg-white/10" : state.className, children: isNext ? "Pr\xF3ximo" : state.label }),
16282
- isNext ? /* @__PURE__ */ jsx(PermissionGate, { feature: "agenda", action: "create", children: /* @__PURE__ */ jsxs(
16283
- Button,
16284
- {
16285
- type: "button",
16286
- size: "sm",
16287
- variant: "outline",
16288
- className: "h-8 border-white/35 bg-white/10 text-white shadow-none hover:bg-white/20 hover:text-white",
16289
- onClick: () => addWalkIn(row.professionalId),
16290
- children: [
16291
- /* @__PURE__ */ jsx(CalendarPlus, { className: "mr-1.5 h-3.5 w-3.5" }),
16292
- "Adicionar encaixe"
16293
- ]
16294
- }
16295
- ) }) : null
16296
- ] })
16297
- ] }) }, row.id);
16298
- }) }),
16299
- (snapshot?.events.length ?? 0) > 0 && /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "p-4", children: [
16300
- /* @__PURE__ */ jsx("h2", { className: "mb-3 text-sm font-semibold", children: "Movimentos de hoje" }),
16301
- /* @__PURE__ */ jsx("div", { className: "grid gap-2", children: snapshot.events.slice(0, 12).map((event) => /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-4 border-b border-border/60 py-2 text-sm last:border-0", children: [
16302
- /* @__PURE__ */ jsxs("div", { children: [
16303
- /* @__PURE__ */ jsx("p", { className: "font-medium", children: event.professionalName }),
16304
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: event.reason ?? event.eventType.replace(/_/g, " ") })
16305
- ] }),
16306
- /* @__PURE__ */ jsx("span", { className: "shrink-0 text-xs text-muted-foreground", children: new Date(event.occurredAt).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }) })
16307
- ] }, event.id)) })
16308
- ] }) })
18870
+ /* @__PURE__ */ jsxs("div", { children: [
18871
+ /* @__PURE__ */ jsx("label", { className: "mb-1 block text-xs font-medium text-muted-foreground", children: "At\xE9" }),
18872
+ /* @__PURE__ */ jsx(DatePicker, { value: to, onChange: setTo })
18873
+ ] }),
18874
+ /* @__PURE__ */ jsxs("div", { children: [
18875
+ /* @__PURE__ */ jsx("label", { className: "mb-1 block text-xs font-medium text-muted-foreground", children: "Unidades" }),
18876
+ /* @__PURE__ */ jsx(MultiSelect, { options: units.map((u) => ({ value: u.id, label: u.name })), value: selected, onChange: setSelected, allLabel: "Todas as unidades" })
18877
+ ] })
18878
+ ] }) }),
18879
+ error ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-6 text-sm text-destructive", children: error }) }) : !rows ? /* @__PURE__ */ jsx("div", { className: "grid gap-3 sm:grid-cols-4", children: [0, 1, 2, 3].map((i) => /* @__PURE__ */ jsx(Skeleton, { className: "h-20 rounded-xl" }, i)) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
18880
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-3 sm:grid-cols-2 lg:grid-cols-4", children: [
18881
+ /* @__PURE__ */ jsx(Kpi, { label: "Atendimentos da vez", value: total }),
18882
+ /* @__PURE__ */ jsx(Kpi, { label: "Profissionais que atenderam", value: served.length }),
18883
+ /* @__PURE__ */ jsx(Kpi, { label: "Diferen\xE7a entre quem mais e quem menos atendeu", value: spread }),
18884
+ /* @__PURE__ */ jsx(Kpi, { label: "Pulos", value: skips })
18885
+ ] }),
18886
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "space-y-3 p-4", children: [
18887
+ /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-2 text-sm font-semibold text-foreground", children: [
18888
+ /* @__PURE__ */ jsx(BarChart3, { className: "h-4 w-4 text-muted-foreground" }),
18889
+ "Atendimentos da vez por profissional"
18890
+ ] }),
18891
+ rows.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nenhum movimento da fila no per\xEDodo." }) : /* @__PURE__ */ jsx("div", { className: "space-y-2", children: [...rows].sort((a, b) => b.atendimentos - a.atendimentos).map((r) => /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)_2.5rem] items-center gap-3 text-sm", children: [
18892
+ /* @__PURE__ */ jsx("span", { className: "truncate text-foreground", children: r.professionalName }),
18893
+ /* @__PURE__ */ jsx("span", { className: "h-2.5 overflow-hidden rounded-full bg-muted", children: /* @__PURE__ */ jsx("span", { className: "block h-full rounded-full bg-primary", style: { width: `${r.atendimentos / peak * 100}%` } }) }),
18894
+ /* @__PURE__ */ jsx("span", { className: "text-right tabular-nums text-muted-foreground", children: r.atendimentos })
18895
+ ] }, `${r.unitId}:${r.professionalId}`)) })
18896
+ ] }) }),
18897
+ rows.length > 0 ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "overflow-x-auto p-0", children: /* @__PURE__ */ jsxs("table", { className: "w-full min-w-[56rem] text-sm", children: [
18898
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { className: "border-b border-border/70 text-left text-xs text-muted-foreground", children: [
18899
+ /* @__PURE__ */ jsx("th", { className: "px-4 py-2.5 font-medium", children: "Profissional" }),
18900
+ /* @__PURE__ */ jsx("th", { className: "px-4 py-2.5 font-medium", children: "Unidade" }),
18901
+ COLUMNS.map((c) => /* @__PURE__ */ jsx("th", { className: "px-3 py-2.5 text-right font-medium", children: c.label }, c.key))
18902
+ ] }) }),
18903
+ /* @__PURE__ */ jsx("tbody", { className: "divide-y divide-border/50", children: rows.map((r) => /* @__PURE__ */ jsxs("tr", { children: [
18904
+ /* @__PURE__ */ jsx("td", { className: "px-4 py-2 text-foreground", children: r.professionalName }),
18905
+ /* @__PURE__ */ jsx("td", { className: "px-4 py-2 text-muted-foreground", children: r.unitName }),
18906
+ COLUMNS.map((c) => /* @__PURE__ */ jsx("td", { className: "px-3 py-2 text-right tabular-nums text-foreground", children: Number(r[c.key]) }, c.key))
18907
+ ] }, `${r.unitId}:${r.professionalId}`)) })
18908
+ ] }) }) }) : null
16309
18909
  ] })
16310
18910
  ] }) });
16311
18911
  }
@@ -16319,9 +18919,10 @@ function UnitTurnQueueWidget({ item }) {
16319
18919
  if (!unitId) return;
16320
18920
  let alive = true;
16321
18921
  const supabase = getSupabaseClientOptional();
16322
- void supabase?.from("plg_agenda_turn_queues").select("is_active, geofence_required, geofence_radius_meters").eq("unit_id", unitId).eq("queue_key", "walk_in").maybeSingle().then(({ data }) => {
18922
+ void supabase?.rpc("agenda_turn_queue_config", { p_unit_id: unitId }).then(({ data }) => {
16323
18923
  if (!alive) return;
16324
- const next = data ?? { is_active: false, geofence_required: true, geofence_radius_meters: 80 };
18924
+ const u = data?.unit;
18925
+ const next = { is_active: u?.enabled ?? false, geofence_required: u?.requireLocation ?? false, geofence_radius_meters: u?.radiusM ?? 80 };
16325
18926
  setRow(next);
16326
18927
  setRadius(String(next.geofence_radius_meters));
16327
18928
  setLoading(false);
@@ -16413,14 +19014,11 @@ function createAgendaTurnQueuePlugin(options = {}) {
16413
19014
  () => createSupabaseTurnQueueProvider(),
16414
19015
  () => createMockTurnQueueProvider()
16415
19016
  );
16416
- const agendaProvider = createSafeDataProvider(
16417
- () => createSupabaseAgendaProvider(),
16418
- () => createMockAgendaProvider()
16419
- );
16420
- const menu = options.labels?.menu ?? "Pr\xF3ximo da vez";
19017
+ const menu = options.labels?.menu ?? "Lista da vez";
16421
19018
  const title = options.labels?.title ?? menu;
16422
- const subtitle = options.labels?.subtitle ?? "Ordem ao vivo dos profissionais dispon\xEDveis nesta unidade";
16423
- const Page = (() => React23.createElement(TurnQueuePage, { provider: provider2, agendaProvider, title, subtitle }));
19019
+ const subtitle = options.labels?.subtitle ?? "A fila justa do cliente sem prefer\xEAncia, por \xE1rea";
19020
+ const Page = (() => React23.createElement(TurnQueuePage, { provider: provider2, title, subtitle }));
19021
+ const ReportPage = (() => React23.createElement(TurnQueueReportPage, { provider: provider2 }));
16424
19022
  const HeaderControl = (() => React23.createElement(AvailabilityHeaderControl, { provider: provider2 }));
16425
19023
  return {
16426
19024
  id: "agenda-turn-queue",
@@ -16438,7 +19036,7 @@ function createAgendaTurnQueuePlugin(options = {}) {
16438
19036
  id: "agenda.turn_queue",
16439
19037
  label: menu,
16440
19038
  group: "Agenda",
16441
- actions: ["read", "create", "edit", "manage"]
19039
+ actions: ["read", "create", "edit", "delete", "manage"]
16442
19040
  }],
16443
19041
  navigation: [{
16444
19042
  section: options.navSection ?? "main",
@@ -16453,6 +19051,11 @@ function createAgendaTurnQueuePlugin(options = {}) {
16453
19051
  component: Page,
16454
19052
  permission: { feature: "agenda", action: "read" },
16455
19053
  title
19054
+ }, {
19055
+ path: "/agenda/turn-queue/report",
19056
+ component: ReportPage,
19057
+ permission: { feature: "agenda", action: "read" },
19058
+ title: "Relat\xF3rio da lista da vez"
16456
19059
  }],
16457
19060
  // Lives on the add-on, not on agenda: the unit tab exists only while this
16458
19061
  // module is enabled in settings/modules.