2644e60bb6
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.
═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════
3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.
3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).
3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).
3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.
3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".
═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════
- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
envolve envio em dedução atômica + rollback). Consumido por Evolution E
Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand
═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════
Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
transactions, packages, purchases) + RPCs add_whatsapp_credits e
deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
de 11 ou 14 dígitos)
Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance
Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
error.context.json()
Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
- Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
toggle is_active inline, dialog de edição com validação
- Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
das últimas 20 transações topup/adjustment/refund
═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════
2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)
2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha
═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════
5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords
═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════
- contact_types + contact_phones (entity_type + entity_id) — migration
20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
e cria/atualiza phone como WhatsApp vinculado
═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════
- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
.dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
(fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
sino + popup + browser notification)
═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════
20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj
═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════
Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound
═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════
Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits
Stores: conversationDrawerStore
Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor
Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage
Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats
Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)
Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados
═══════════════════════════════════════════════════════════════════════════
NOTAS
- Após subir, rodar supabase functions serve --no-verify-jwt
--env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2750 lines
112 KiB
Vue
2750 lines
112 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/agenda/pages/AgendaClinicaPage.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, onMounted, ref, watch, nextTick } from 'vue';
|
|
import { useRouter, useRoute } from 'vue-router';
|
|
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { useConfirm } from 'primevue/useconfirm';
|
|
|
|
import DatePicker from 'primevue/datepicker';
|
|
|
|
import AgendaClinicMosaic from '@/features/agenda/components/AgendaClinicMosaic.vue';
|
|
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
|
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue';
|
|
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
|
|
|
|
import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff';
|
|
import { useAgendaClinicEvents } from '@/features/agenda/composables/useAgendaClinicEvents';
|
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
|
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
|
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
|
import { useFeriados } from '@/composables/useFeriados';
|
|
|
|
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
|
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const tenantStore = useTenantStore();
|
|
|
|
// Data inicial via query param ?date=YYYY-MM-DD (vindo de "Ver na agenda")
|
|
const _queryDate = route.query.date ? new Date(route.query.date + 'T12:00:00') : null;
|
|
|
|
// -------------------- feriados --------------------
|
|
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
|
|
|
|
onMounted(async () => {
|
|
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
|
if (tid) loadFeriados(tid);
|
|
});
|
|
|
|
// -------------------- hero sticky --------------------
|
|
const headerSentinelRef = ref(null);
|
|
const headerEl = ref(null);
|
|
const headerStuck = ref(false);
|
|
const headerMenuRef = ref(null);
|
|
|
|
const headerMenuItems = computed(() => [
|
|
{ label: 'Novo compromisso', icon: 'pi pi-plus', command: () => onCreateFromButton() },
|
|
{
|
|
label: 'Buscar',
|
|
icon: 'pi pi-search',
|
|
command: () => {
|
|
searchModalOpen.value = true;
|
|
}
|
|
},
|
|
{ separator: true },
|
|
{ label: 'Hoje', icon: 'pi pi-calendar', command: () => goToday() },
|
|
{ label: 'Anterior', icon: 'pi pi-chevron-left', command: () => goPrev() },
|
|
{ label: 'Próximo', icon: 'pi pi-chevron-right', command: () => goNext() },
|
|
{ separator: true },
|
|
{
|
|
label: 'Bloquear horário',
|
|
icon: 'pi pi-clock',
|
|
command: () => {
|
|
bloqueioMode.value = 'horario';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
},
|
|
{
|
|
label: 'Bloquear período',
|
|
icon: 'pi pi-calendar-minus',
|
|
command: () => {
|
|
bloqueioMode.value = 'periodo';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
},
|
|
{
|
|
label: 'Bloquear dia',
|
|
icon: 'pi pi-ban',
|
|
command: () => {
|
|
bloqueioMode.value = 'dia';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
},
|
|
{
|
|
label: 'Bloquear feriados',
|
|
icon: 'pi pi-flag',
|
|
command: () => {
|
|
bloqueioMode.value = 'feriados';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
},
|
|
...(feriadosSemBloqueio.value.length
|
|
? [
|
|
{
|
|
label: `Feriados sem bloqueio (${feriadosSemBloqueio.value.length})`,
|
|
icon: 'pi pi-bell',
|
|
command: () => {
|
|
feriadosAlertaOpen.value = true;
|
|
}
|
|
}
|
|
]
|
|
: []),
|
|
{ separator: true },
|
|
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => refetch() },
|
|
{ label: 'Recorrências', icon: 'pi pi-sync', command: () => goRecorrencias() },
|
|
{ label: 'Configurações', icon: 'pi pi-cog', command: () => goSettings() }
|
|
]);
|
|
|
|
const calendarRef = ref(null);
|
|
|
|
const { error: settingsError, settings, workRules, load: loadSettings } = useAgendaSettings();
|
|
|
|
const onlySessions = ref(true);
|
|
const calendarView = ref('day');
|
|
const timeMode = ref('my');
|
|
|
|
const search = ref('');
|
|
const searchModalOpen = ref(false);
|
|
const miniDate = ref(_queryDate || new Date());
|
|
const monthPickerVisible = ref(false);
|
|
const monthPickerDate = ref(new Date());
|
|
|
|
const currentRange = ref({ start: null, end: null });
|
|
const currentDate = ref(new Date());
|
|
|
|
const dialogOpen = ref(false);
|
|
const dialogEventRow = ref(null);
|
|
const dialogStartISO = ref('');
|
|
const dialogEndISO = ref('');
|
|
const dialogOwnerId = ref('');
|
|
const dialogPresetCommitmentId = ref(null);
|
|
const dialogLockCommitment = ref(false);
|
|
|
|
// ── Bloqueio de agenda ─────────────────────────────────────
|
|
const blockMenuRef = ref(null);
|
|
const bloqueioDialogOpen = ref(false);
|
|
const bloqueioMode = ref('dia');
|
|
|
|
const blockMenuItems = computed(() => [
|
|
{
|
|
label: 'Bloquear horário',
|
|
icon: 'pi pi-clock',
|
|
command: () => {
|
|
bloqueioMode.value = 'horario';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
},
|
|
{
|
|
label: 'Bloquear período',
|
|
icon: 'pi pi-calendar-minus',
|
|
command: () => {
|
|
bloqueioMode.value = 'periodo';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
},
|
|
{
|
|
label: 'Bloquear dia inteiro',
|
|
icon: 'pi pi-ban',
|
|
command: () => {
|
|
bloqueioMode.value = 'dia';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
},
|
|
{
|
|
label: 'Bloquear feriados',
|
|
icon: 'pi pi-flag',
|
|
command: () => {
|
|
bloqueioMode.value = 'feriados';
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
}
|
|
]);
|
|
|
|
const onlySessionsOptions = [
|
|
{ label: 'Apenas Sessões', value: true },
|
|
{ label: 'Tudo', value: false }
|
|
];
|
|
const viewOptions = [
|
|
{ label: 'Dia', value: 'day' },
|
|
{ label: 'Semana', value: 'week' },
|
|
{ label: 'Mês', value: 'month' },
|
|
{ label: 'Lista', value: 'list' }
|
|
];
|
|
const timeModeOptions = [
|
|
{ label: '24h', value: '24' },
|
|
{ label: '12h', value: '12' },
|
|
{ label: 'Meu Horário', value: 'my' }
|
|
];
|
|
|
|
const mosaicMode = ref('work_hours');
|
|
|
|
const mosaicModeOptions = [
|
|
{ label: 'Horas de Trabalho', value: 'work_hours' },
|
|
{ label: 'Grade Completa', value: 'full_24h' }
|
|
];
|
|
|
|
// Sincroniza mosaicMode com timeMode: '24h' força grade completa
|
|
watch(timeMode, (v) => {
|
|
if (v === '24') mosaicMode.value = 'full_24h';
|
|
else if (mosaicMode.value === 'full_24h') mosaicMode.value = 'work_hours';
|
|
});
|
|
|
|
function settingsFallbackStart() {
|
|
const s = settings.value;
|
|
return (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '08:00:00';
|
|
}
|
|
function settingsFallbackEnd() {
|
|
const s = settings.value;
|
|
return (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '18:00:00';
|
|
}
|
|
|
|
const businessHours = computed(() => {
|
|
if (timeMode.value === '24') return [];
|
|
const rules = workRules.value;
|
|
if (rules.length > 0) {
|
|
return rules.map((r) => ({
|
|
daysOfWeek: [r.dia_semana],
|
|
startTime: String(r.hora_inicio || '').slice(0, 5),
|
|
endTime: String(r.hora_fim || '').slice(0, 5)
|
|
}));
|
|
}
|
|
return [{ daysOfWeek: [1, 2, 3, 4, 5], startTime: settingsFallbackStart(), endTime: settingsFallbackEnd() }];
|
|
});
|
|
|
|
// Retorna { start, end } para o modo 'my' de acordo com a view e o dia atual
|
|
function floorTo30(hhmmss) {
|
|
const [h, m] = String(hhmmss || '00:00')
|
|
.slice(0, 5)
|
|
.split(':')
|
|
.map(Number);
|
|
const rm = m < 30 ? 0 : 30;
|
|
return `${String(h).padStart(2, '0')}:${String(rm).padStart(2, '0')}:00`;
|
|
}
|
|
function ceilTo30(hhmmss) {
|
|
const [h, m] = String(hhmmss || '00:00')
|
|
.slice(0, 5)
|
|
.split(':')
|
|
.map(Number);
|
|
if (m === 0 || m === 30) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
|
|
if (m < 30) return `${String(h).padStart(2, '0')}:30:00`;
|
|
return `${String(h + 1).padStart(2, '0')}:00:00`;
|
|
}
|
|
|
|
function myHoursRange() {
|
|
const rules = workRules.value;
|
|
if (rules.length === 0) {
|
|
return { start: floorTo30(settingsFallbackStart()), end: ceilTo30(settingsFallbackEnd()) };
|
|
}
|
|
if (calendarView.value === 'day') {
|
|
const dow = currentDate.value.getDay();
|
|
const rule = rules.find((r) => r.dia_semana === dow);
|
|
if (rule) return { start: floorTo30(rule.hora_inicio), end: ceilTo30(rule.hora_fim) };
|
|
return { start: '00:00:00', end: '24:00:00' };
|
|
}
|
|
const starts = rules.map((r) => floorTo30(r.hora_inicio));
|
|
const ends = rules.map((r) => ceilTo30(r.hora_fim));
|
|
return {
|
|
start: starts.reduce((a, b) => (a < b ? a : b)),
|
|
end: ends.reduce((a, b) => (a > b ? a : b))
|
|
};
|
|
}
|
|
|
|
const slotMinTime = computed(() => {
|
|
if (timeMode.value === '24') return '00:00:00';
|
|
if (timeMode.value === '12') return '06:00:00';
|
|
return myHoursRange().start;
|
|
});
|
|
|
|
const slotMaxTime = computed(() => {
|
|
if (timeMode.value === '24') return '24:00:00';
|
|
if (timeMode.value === '12') return '18:00:00';
|
|
return myHoursRange().end;
|
|
});
|
|
|
|
// Grade visual sempre em 15 min — a duração da sessão (ex: 1h06) é usada só no dialog.
|
|
const slotDuration = '00:15:00'; // grade visual sempre 15min
|
|
|
|
const snapDuration = '00:15:00';
|
|
const slotLabelInterval = '00:30';
|
|
|
|
const ptCalendarLocale = {
|
|
firstDayOfWeek: 1,
|
|
dayNames: ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'],
|
|
dayNamesShort: ['dom', 'seg', 'ter', 'qua', 'qui', 'sex', 'sáb'],
|
|
dayNamesMin: ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'],
|
|
monthNames: ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'],
|
|
monthNamesShort: ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'],
|
|
today: 'Hoje',
|
|
clear: 'Limpar'
|
|
};
|
|
|
|
const tenantId = computed(() => {
|
|
const t = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id;
|
|
if (!t) return null;
|
|
if (t === 'null' || t === 'undefined') return null;
|
|
return t;
|
|
});
|
|
|
|
// owner_id do admin da clínica = user UUID do usuário logado.
|
|
// Fonte primária: agenda_configuracoes.owner_id (via settings).
|
|
// Fallback: tenantStore.user.id — garante que a coluna da clínica aparece mesmo
|
|
// quando o admin ainda não configurou a agenda (sem linha em agenda_configuracoes).
|
|
// NÃO usar tenantId aqui — owner_id em agenda_eventos é sempre um user UUID.
|
|
const clinicOwnerId = computed(() => String(settings.value?.owner_id || tenantStore.user?.id || '').trim());
|
|
|
|
function isUuid(v) {
|
|
return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
|
|
}
|
|
|
|
function normalizeDay(input) {
|
|
const d = input instanceof Date ? input : new Date(input);
|
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12, 0, 0, 0));
|
|
}
|
|
|
|
// -------------------- commitments --------------------
|
|
const { rows: determinedCommitments, load: loadDeterminedCommitments, loading: dcLoading, error: dcError } = useDeterminedCommitments(tenantId);
|
|
|
|
const commitmentOptionsNormalized = computed(() => {
|
|
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : [];
|
|
const priority = new Map([
|
|
['session', 0],
|
|
['class', 1],
|
|
['study', 2],
|
|
['reading', 3],
|
|
['supervision', 4],
|
|
['content_creation', 5]
|
|
]);
|
|
return [...list]
|
|
.filter((i) => i?.id && i?.active !== false)
|
|
.sort((a, b) => {
|
|
const pa = priority.has(a.native_key) ? priority.get(a.native_key) : 99;
|
|
const pb = priority.has(b.native_key) ? priority.get(b.native_key) : 99;
|
|
if (pa !== pb) return pa - pb;
|
|
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR');
|
|
})
|
|
.map((i) => ({
|
|
id: i.id,
|
|
tenant_id: i.tenant_id ?? null,
|
|
created_by: i.created_by ?? null,
|
|
name: String(i.name || '').trim() || 'Sem nome',
|
|
description: i.description || '',
|
|
native_key: i.native_key ?? null,
|
|
is_native: !!i.is_native,
|
|
is_locked: !!i.is_locked,
|
|
active: i.active !== false,
|
|
bg_color: i.bg_color || null,
|
|
text_color: i.text_color || null,
|
|
fields: Array.isArray(i.determined_commitment_fields) ? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) : []
|
|
}));
|
|
});
|
|
|
|
function getSessionCommitmentId() {
|
|
const all = commitmentOptionsNormalized.value || [];
|
|
const found = all.find((c) => String(c?.native_key || '').toLowerCase() === 'session');
|
|
return found?.id || null;
|
|
}
|
|
|
|
const commitmentById = computed(() => {
|
|
const map = new Map();
|
|
for (const c of commitmentOptionsNormalized.value || []) {
|
|
if (c?.id) map.set(String(c.id), c);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
function isSessionCommitmentId(commitmentId) {
|
|
if (!commitmentId) return false;
|
|
const c = commitmentById.value.get(String(commitmentId));
|
|
return String(c?.native_key || '').toLowerCase() === 'session';
|
|
}
|
|
|
|
const filteredCommitmentOptions = computed(() => {
|
|
const owner = String(dialogOwnerId.value || '').trim();
|
|
const clinic = clinicOwnerId.value;
|
|
const all = commitmentOptionsNormalized.value || [];
|
|
if (!owner) return [];
|
|
|
|
if (clinic && owner !== clinic) {
|
|
return all.filter((c) => String(c?.native_key || '').toLowerCase() === 'session');
|
|
}
|
|
return all;
|
|
});
|
|
|
|
async function ensureCommitmentsLoaded() {
|
|
if (!tenantId.value) {
|
|
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Tenant ainda não carregou.', life: 2500 });
|
|
return false;
|
|
}
|
|
if (commitmentOptionsNormalized.value?.length) return true;
|
|
if (dcLoading.value) {
|
|
await nextTick();
|
|
return !!commitmentOptionsNormalized.value?.length;
|
|
}
|
|
await loadDeterminedCommitments();
|
|
if (dcError.value) {
|
|
toast.add({ severity: 'warn', summary: 'Compromissos', detail: dcError.value, life: 4500 });
|
|
return false;
|
|
}
|
|
if (!commitmentOptionsNormalized.value?.length) {
|
|
toast.add({ severity: 'warn', summary: 'Compromissos', detail: 'Nenhum compromisso encontrado para este tenant.', life: 4500 });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// -------------------- staff --------------------
|
|
const { loading: loadingStaff, error: staffError, staff, load: loadStaff } = useAgendaClinicStaff();
|
|
|
|
const tenantMemberIdByUserId = computed(() => {
|
|
const map = new Map();
|
|
for (const s of staff.value || []) {
|
|
const userId = String(s?.user_id || s?.userId || '').trim();
|
|
const memberId = String(s?.tenant_member_id || s?.tenantMemberId || s?.tenant_memberId || s?.member_id || s?.memberId || '').trim();
|
|
|
|
if (isUuid(userId) && isUuid(memberId)) map.set(userId, memberId);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
const staffCols = computed(() => {
|
|
return (staff.value || [])
|
|
.filter((s) => isUuid(s.user_id))
|
|
.map((s) => ({
|
|
id: s.user_id,
|
|
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
|
|
}));
|
|
});
|
|
|
|
const ownerIds = computed(() => {
|
|
const staffIds = staffCols.value.map((p) => p.id);
|
|
const clinicId = clinicOwnerId.value;
|
|
// inclui o admin da clínica para buscar os eventos da coluna própria
|
|
return clinicId ? [...new Set([clinicId, ...staffIds])] : staffIds;
|
|
});
|
|
const ownerOptions = computed(() => staffCols.value.map((p) => ({ label: p.title, value: p.id })));
|
|
|
|
// -------------------- events --------------------
|
|
const { loading: loadingEvents, error: eventsError, rows, loadClinicRange, createClinic, updateClinic, removeClinic } = useAgendaClinicEvents();
|
|
|
|
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
|
|
|
|
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
|
|
|
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
|
|
function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
|
|
const s = String(t || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!s) return fallback;
|
|
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
|
|
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
|
return fallback;
|
|
}
|
|
|
|
function maskPrivateRow(r) {
|
|
return {
|
|
...r,
|
|
titulo: 'Ocupado',
|
|
observacoes: null,
|
|
paciente_id: null,
|
|
paciente_nome: null,
|
|
patient_name: null,
|
|
visibility_scope: 'busy_only',
|
|
masked: true
|
|
};
|
|
}
|
|
|
|
const baseRows = computed(() => {
|
|
const list = rows.value || [];
|
|
const refined = list.map((r) => {
|
|
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO);
|
|
const vis = String(r.visibility_scope || '').toLowerCase();
|
|
|
|
if (vis === 'busy_only') return maskPrivateRow(r);
|
|
|
|
if (tipo === EVENTO_TIPO.SESSAO) {
|
|
const dcId = r.determined_commitment_id || r.determined_commitmentId || null;
|
|
if (!dcId) return r;
|
|
if (!isSessionCommitmentId(dcId)) return maskPrivateRow(r);
|
|
return r;
|
|
}
|
|
|
|
return maskPrivateRow(r);
|
|
});
|
|
|
|
if (!onlySessions.value) return refined;
|
|
// Filtrar por patient_id — filtro por tipo não funciona pois o enum do banco
|
|
// usa 'sessao' para todos os compromissos não-bloqueio (Análise, Leitura, etc.)
|
|
return refined.filter((r) => !!(r.patient_id || r.masked));
|
|
});
|
|
|
|
const allEvents = computed(() => {
|
|
// Mapa id → cores para injetar em ocorrências virtuais (que não têm o join determined_commitments)
|
|
const colorMap = new Map((commitmentOptionsNormalized.value || []).filter((c) => c.id).map((c) => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }]));
|
|
function withCommitmentColors(r) {
|
|
if (r.determined_commitments || !r.determined_commitment_id) return r;
|
|
const colors = colorMap.get(r.determined_commitment_id);
|
|
return colors ? { ...r, determined_commitments: colors } : r;
|
|
}
|
|
|
|
// eventos reais (sem ocorrências virtuais para evitar duplicatas)
|
|
const realRows = (baseRows.value || []).filter((r) => !r.is_occurrence).map(withCommitmentColors);
|
|
const base = mapAgendaEventosToCalendarEvents(realRows);
|
|
|
|
// ocorrências virtuais das séries
|
|
const occRows = (_occurrenceRows.value || [])
|
|
.map((r) => {
|
|
// aplica máscara de privacidade se necessário
|
|
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO);
|
|
const dc = r.determined_commitment_id;
|
|
if (tipo === EVENTO_TIPO.SESSAO && dc && !isSessionCommitmentId(dc)) return maskPrivateRow(r);
|
|
if (onlySessions.value && !(r.patient_id || r.masked)) return null;
|
|
return withCommitmentColors(r);
|
|
})
|
|
.filter(Boolean);
|
|
const occEvents = mapAgendaEventosToCalendarEvents(occRows);
|
|
|
|
return [...base, ...occEvents, ...feriadoFcEvents.value];
|
|
});
|
|
|
|
// -------------------- eventos fora da jornada --------------------
|
|
function timeStrToMin(t) {
|
|
const [h, m] = String(t || '00:00')
|
|
.split(':')
|
|
.map(Number);
|
|
return h * 60 + m;
|
|
}
|
|
|
|
const hasEventsOutsideWorkHours = computed(() => {
|
|
if (timeMode.value === '24') return false;
|
|
const rules = workRules.value;
|
|
if (!rules.length) return false;
|
|
|
|
return (baseRows.value || []).some((r) => {
|
|
if (!r.inicio_em) return false;
|
|
const d = new Date(r.inicio_em);
|
|
const dow = d.getDay();
|
|
const rule = rules.find((rr) => Number(rr.dia_semana) === dow);
|
|
if (!rule) return true;
|
|
|
|
const ruleStart = timeStrToMin(String(rule.hora_inicio || '').slice(0, 5));
|
|
const ruleEnd = timeStrToMin(String(rule.hora_fim || '').slice(0, 5));
|
|
const evStart = d.getHours() * 60 + d.getMinutes();
|
|
if (evStart < ruleStart) return true;
|
|
if (r.fim_em) {
|
|
const e = new Date(r.fim_em);
|
|
if (e.getHours() * 60 + e.getMinutes() > ruleEnd) return true;
|
|
}
|
|
return false;
|
|
});
|
|
});
|
|
|
|
watch(
|
|
hasEventsOutsideWorkHours,
|
|
(hasOut) => {
|
|
if (hasOut && timeMode.value === 'my') timeMode.value = '24';
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// -------------------- search --------------------
|
|
const searchTrim = computed(() => String(search.value || '').trim());
|
|
const searchScope = ref('month'); // 'range' | 'month'
|
|
const monthSearchRows = ref([]);
|
|
const monthSearchLoading = ref(false);
|
|
|
|
function _matchRow(r, q) {
|
|
return (
|
|
String(r.titulo || '')
|
|
.toLowerCase()
|
|
.includes(q) ||
|
|
String(r.observacoes || '')
|
|
.toLowerCase()
|
|
.includes(q) ||
|
|
String(r.tipo || '')
|
|
.toLowerCase()
|
|
.includes(q) ||
|
|
String(r.paciente_nome || r.patient_name || r.nome_paciente || '')
|
|
.toLowerCase()
|
|
.includes(q)
|
|
);
|
|
}
|
|
|
|
const searchResults = computed(() => {
|
|
const q = searchTrim.value.toLowerCase();
|
|
if (!q) return [];
|
|
const source = searchScope.value === 'month' ? monthSearchRows.value : baseRows.value || [];
|
|
return source.filter((r) => _matchRow(r, q));
|
|
});
|
|
|
|
async function loadMonthSearchRows() {
|
|
const tid = tenantId.value;
|
|
const ids = ownerIds.value;
|
|
if (!tid || !ids.length) return;
|
|
const d = currentDate.value;
|
|
const start = new Date(d.getFullYear(), d.getMonth(), 1).toISOString();
|
|
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString();
|
|
monthSearchLoading.value = true;
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('agenda_eventos')
|
|
.select(
|
|
'id, owner_id, tenant_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, patients!agenda_eventos_patient_id_fkey(nome_completo)'
|
|
)
|
|
.eq('tenant_id', tid)
|
|
.in('owner_id', ids)
|
|
.is('mirror_of_event_id', null)
|
|
.gte('inicio_em', start)
|
|
.lt('inicio_em', end)
|
|
.order('inicio_em', { ascending: true });
|
|
if (error) throw error;
|
|
monthSearchRows.value = (data || []).map((r) => ({ ...r, paciente_nome: r.patients?.nome_completo || '' }));
|
|
} catch {
|
|
monthSearchRows.value = [];
|
|
} finally {
|
|
monthSearchLoading.value = false;
|
|
}
|
|
}
|
|
|
|
watch([searchTrim, searchScope], ([q, scope]) => {
|
|
if (q && scope === 'month') loadMonthSearchRows();
|
|
else if (!q) monthSearchRows.value = [];
|
|
});
|
|
|
|
// Recarrega mês ao mudar de mês no calendário (currentDate mudou de mês)
|
|
watch(currentDate, (newD, oldD) => {
|
|
if (searchTrim.value && searchScope.value === 'month' && (newD.getFullYear() !== oldD?.getFullYear() || newD.getMonth() !== oldD?.getMonth())) loadMonthSearchRows();
|
|
});
|
|
|
|
// -------------------- titles --------------------
|
|
function capitalize(s) {
|
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
}
|
|
|
|
function padTime(hhmmss, deltaMin) {
|
|
const [hh, mm] = String(hhmmss || '00:00:00')
|
|
.split(':')
|
|
.map(Number);
|
|
let total = hh * 60 + mm + deltaMin;
|
|
if (total < 0) total = 0;
|
|
if (total > 24 * 60) total = 24 * 60;
|
|
return minutesToDuration(total);
|
|
}
|
|
|
|
function deriveTituloDefaultByTipo(tipo) {
|
|
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
|
|
}
|
|
|
|
const visibleTitle = computed(() => {
|
|
const d = currentDate.value;
|
|
const month = d.toLocaleDateString('pt-BR', { month: 'long' });
|
|
const year = d.getFullYear();
|
|
return `${capitalize(month)} ${year}`;
|
|
});
|
|
|
|
const subtitleText = computed(() => {
|
|
if (calendarView.value === 'month') return visibleTitle.value;
|
|
const d = currentDate.value;
|
|
const weekday = d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
|
const day = d.getDate();
|
|
const month = d.toLocaleDateString('pt-BR', { month: 'long' });
|
|
return `${capitalize(weekday)}, ${day} de ${capitalize(month)}`;
|
|
});
|
|
|
|
// -------------------- lifecycle --------------------
|
|
const booted = ref(false);
|
|
|
|
async function bootIfPossible() {
|
|
const tid = tenantId.value;
|
|
if (!tid) return;
|
|
if (booted.value) return;
|
|
booted.value = true;
|
|
|
|
await loadSettings();
|
|
if (settingsError.value) toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 });
|
|
|
|
await loadStaff(tid);
|
|
if (staffError.value) toast.add({ severity: 'warn', summary: 'Profissionais', detail: staffError.value, life: 4500 });
|
|
|
|
await loadDeterminedCommitments();
|
|
if (dcError.value) toast.add({ severity: 'warn', summary: 'Compromissos', detail: dcError.value, life: 4500 });
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await bootIfPossible();
|
|
|
|
// Navega para a data do query param ?date= se presente
|
|
if (_queryDate) {
|
|
await nextTick();
|
|
calendarRef.value?.gotoDate?.(_queryDate);
|
|
}
|
|
|
|
// Sticky detection: sentinel sai da área visível → header colado
|
|
if (headerSentinelRef.value) {
|
|
const io = new IntersectionObserver(
|
|
([entry]) => {
|
|
headerStuck.value = !entry.isIntersecting;
|
|
},
|
|
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
|
|
);
|
|
io.observe(headerSentinelRef.value);
|
|
}
|
|
});
|
|
watch(
|
|
tenantId,
|
|
async (tid) => {
|
|
if (tid) await bootIfPossible();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// -------------------- mosaic navigation --------------------
|
|
function goToday() {
|
|
calendarRef.value?.goToday?.();
|
|
}
|
|
function goPrev() {
|
|
calendarRef.value?.prev?.();
|
|
}
|
|
function goNext() {
|
|
calendarRef.value?.next?.();
|
|
}
|
|
|
|
watch(calendarView, (v) => {
|
|
calendarRef.value?.setView?.(v);
|
|
});
|
|
|
|
// -------------------- range load --------------------
|
|
const pendingRange = ref({ start: null, end: null });
|
|
|
|
async function maybeLoadRange() {
|
|
const tid = tenantId.value;
|
|
const start = pendingRange.value.start;
|
|
const end = pendingRange.value.end;
|
|
const ids = ownerIds.value;
|
|
|
|
if (!tid || !start || !end) return;
|
|
if (!ids || !ids.length) return;
|
|
|
|
const startDate = new Date(start);
|
|
const endDate = new Date(end);
|
|
|
|
await loadClinicRange({
|
|
tenantId: tid,
|
|
ownerIds: ids,
|
|
startISO: startDate.toISOString(),
|
|
endISO: endDate.toISOString()
|
|
});
|
|
|
|
if (eventsError.value) {
|
|
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 });
|
|
}
|
|
|
|
// Expande ocorrências virtuais de recorrência para cada owner no range
|
|
const allMerged = [];
|
|
for (const ownId of ids) {
|
|
const merged = await loadAndExpand(
|
|
ownId,
|
|
startDate,
|
|
endDate,
|
|
rows.value.filter((r) => r.owner_id === ownId),
|
|
tid
|
|
);
|
|
allMerged.push(...merged.filter((r) => r.is_occurrence));
|
|
}
|
|
_occurrenceRows.value = allMerged;
|
|
}
|
|
|
|
async function onRangeChange({ start, end, currentDate: cd }) {
|
|
const prevStart = pendingRange.value.start?.toString();
|
|
const prevEnd = pendingRange.value.end?.toString();
|
|
pendingRange.value = { start, end };
|
|
currentRange.value = { start, end };
|
|
const base = cd || start || new Date();
|
|
const newDate = normalizeDay(base) || new Date();
|
|
|
|
// Só atualiza se o dia realmente mudou — evita loop: setOption → rangeChange → slotMinTime recomputa → watch → setOption
|
|
if (currentDate.value.toDateString() !== newDate.toDateString()) {
|
|
currentDate.value = newDate;
|
|
miniDate.value = normalizeDay(newDate);
|
|
}
|
|
|
|
// Recarrega sempre que o range mudar OU quando _occurrenceRows estiver vazio
|
|
if (start?.toString() !== prevStart || end?.toString() !== prevEnd || _occurrenceRows.value.length === 0) {
|
|
await maybeLoadRange();
|
|
}
|
|
}
|
|
|
|
watch(ownerIds, async (ids) => {
|
|
if (ids && ids.length) await maybeLoadRange();
|
|
});
|
|
watch(tenantId, async (tid) => {
|
|
if (tid) await maybeLoadRange();
|
|
});
|
|
|
|
// Persiste a data atual na URL (?date=) para que o F5 mantenha o dia visualizado
|
|
watch(currentDate, (d) => {
|
|
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
if (route.query.date !== iso) {
|
|
router.replace({ query: { ...route.query, date: iso } });
|
|
}
|
|
});
|
|
|
|
watch(searchTrim, (v) => {
|
|
if (!v) searchModalOpen.value = false;
|
|
});
|
|
|
|
// -------------------- debug console --------------------
|
|
function logGroup(title, obj) {
|
|
console.groupCollapsed(title);
|
|
try {
|
|
console.table(obj);
|
|
} catch {
|
|
console.log(obj);
|
|
}
|
|
console.groupEnd();
|
|
}
|
|
|
|
function pickStaffInfoByUserId(userId) {
|
|
const list = staff.value || [];
|
|
return list.find((s) => String(s?.user_id || s?.userId || '') === String(userId || '')) || null;
|
|
}
|
|
|
|
async function debugPatientsForColumn(staffUserId) {
|
|
const tid = tenantId.value;
|
|
if (!tid) return console.warn('[AGENDA DEBUG] tenantId vazio');
|
|
if (!isUuid(staffUserId)) return console.warn('[AGENDA DEBUG] staffUserId inválido:', staffUserId);
|
|
|
|
const staffRow = pickStaffInfoByUserId(staffUserId);
|
|
const memberId = tenantMemberIdByUserId.value.get(String(staffUserId)) || null;
|
|
|
|
console.group(`🧪 AGENDA DEBUG — coluna`);
|
|
console.log('tenantId:', tid);
|
|
console.log('staffUserId:', staffUserId);
|
|
console.log('staffRow:', staffRow);
|
|
console.log('tenant_member_id (mapeado):', memberId);
|
|
|
|
try {
|
|
const { count, error } = await supabase.from('patients').select('id', { count: 'exact', head: true }).eq('tenant_id', tid);
|
|
if (error) throw error;
|
|
console.log('patients total no tenant:', count);
|
|
} catch (e) {
|
|
console.warn('Falha count tenant:', e?.message || e);
|
|
}
|
|
|
|
if (memberId && isUuid(memberId)) {
|
|
try {
|
|
const { count, error } = await supabase.from('patients').select('id', { count: 'exact', head: true }).eq('tenant_id', tid).eq('responsible_member_id', memberId);
|
|
if (error) throw error;
|
|
console.log('patients por responsible_member_id:', count);
|
|
} catch (e) {
|
|
console.warn('Falha count by responsible_member_id:', e?.message || e);
|
|
}
|
|
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('patients')
|
|
.select('id,nome_completo,email_principal,telefone,responsible_member_id,created_at')
|
|
.eq('tenant_id', tid)
|
|
.eq('responsible_member_id', memberId)
|
|
.order('created_at', { ascending: false })
|
|
.limit(5);
|
|
if (error) throw error;
|
|
logGroup('📌 sample patients (até 5)', data || []);
|
|
} catch (e) {
|
|
console.warn('Falha sample:', e?.message || e);
|
|
}
|
|
} else {
|
|
console.warn('⚠️ Não achei tenant_member_id para esse terapeuta (staff mapping).');
|
|
}
|
|
|
|
console.groupEnd();
|
|
}
|
|
|
|
async function onDebugColumn(payload) {
|
|
console.log('🧷 debugColumn payload:', payload);
|
|
const id = payload?.staffUserId || payload?.staffCol?.id || null;
|
|
if (id) await debugPatientsForColumn(String(id));
|
|
}
|
|
|
|
// -------------------- open dialog helper --------------------
|
|
async function openDialogCreate({ ownerId, start, end }) {
|
|
const ok = await ensureCommitmentsLoaded();
|
|
if (!ok) return;
|
|
|
|
const rawStart = new Date(start);
|
|
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50;
|
|
|
|
dialogEventRow.value = null;
|
|
dialogStartISO.value = rawStart.toISOString();
|
|
dialogEndISO.value = new Date(rawStart.getTime() + durMin * 60000).toISOString();
|
|
dialogOwnerId.value = String(ownerId || '').trim();
|
|
|
|
const clinic = clinicOwnerId.value;
|
|
const isStaffColumn = clinic && dialogOwnerId.value && dialogOwnerId.value !== clinic;
|
|
|
|
if (isStaffColumn) {
|
|
dialogPresetCommitmentId.value = getSessionCommitmentId();
|
|
dialogLockCommitment.value = true;
|
|
} else {
|
|
dialogPresetCommitmentId.value = null;
|
|
dialogLockCommitment.value = false;
|
|
}
|
|
|
|
dialogOpen.value = true;
|
|
}
|
|
|
|
async function onSlotSelect({ ownerId, start, end }) {
|
|
await openDialogCreate({ ownerId, start, end });
|
|
}
|
|
|
|
// -------------------- event click/drag --------------------
|
|
async function onEventClick(info) {
|
|
const ok = await ensureCommitmentsLoaded();
|
|
if (!ok) return;
|
|
|
|
const ev = info?.event;
|
|
if (!ev) return;
|
|
|
|
const ep = ev.extendedProps || {};
|
|
dialogEventRow.value = {
|
|
id: ep.isOccurrence ? null : ev.id || null,
|
|
owner_id: ep.owner_id,
|
|
terapeuta_id: ep.terapeuta_id ?? null,
|
|
paciente_id: ep.paciente_id ?? null,
|
|
paciente_nome: ep.paciente_nome ?? null,
|
|
paciente_avatar: ep.paciente_avatar ?? null,
|
|
tipo: ep.tipo,
|
|
status: ep.status,
|
|
titulo: ev.title,
|
|
observacoes: ep.observacoes ?? null,
|
|
visibility_scope: ep.visibility_scope ?? null,
|
|
inicio_em: ev.start?.toISOString?.() || ev.startStr,
|
|
fim_em: ev.end?.toISOString?.() || ev.endStr,
|
|
determined_commitment_id: ep.determined_commitment_id ?? null,
|
|
titulo_custom: ep.titulo_custom ?? null,
|
|
extra_fields: ep.extra_fields ?? null,
|
|
price: ep.price != null ? Number(ep.price) : null,
|
|
insurance_plan_id: ep.insurance_plan_id ?? null,
|
|
insurance_guide_number: ep.insurance_guide_number ?? null,
|
|
insurance_value: ep.insurance_value != null ? Number(ep.insurance_value) : null,
|
|
insurance_plan_service_id: ep.insurance_plan_service_id ?? null,
|
|
// ── recorrência (nova arquitetura) ──────────────────────────
|
|
recurrence_id: ep.recurrenceId ?? ep.recurrence_id ?? ep.serie_id ?? null,
|
|
original_date: ep.originalDate ?? ep.original_date ?? ep.recurrence_date ?? null,
|
|
is_occurrence: ep.isOccurrence ?? false,
|
|
exception_type: ep.exceptionType ?? null,
|
|
// ── fallback legado ─────────────────────────────────────────
|
|
serie_id: ep.serie_id ?? ep.recurrenceId ?? null,
|
|
serie_dia_semana: ep.serie_dia_semana ?? null,
|
|
serie_hora: ep.serie_hora ?? null
|
|
};
|
|
|
|
dialogOwnerId.value = dialogEventRow.value?.owner_id || dialogEventRow.value?.terapeuta_id || clinicOwnerId.value;
|
|
dialogStartISO.value = '';
|
|
dialogEndISO.value = '';
|
|
|
|
dialogPresetCommitmentId.value = null;
|
|
dialogLockCommitment.value = false;
|
|
|
|
dialogOpen.value = true;
|
|
}
|
|
|
|
async function persistMoveOrResize(info, actionLabel) {
|
|
const tid = tenantId.value;
|
|
if (!tid) {
|
|
info?.revert?.();
|
|
toast.add({ severity: 'warn', summary: 'Tenant', detail: 'Selecione uma clínica antes de mover eventos.', life: 3500 });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const ev = info?.event;
|
|
if (!ev) return;
|
|
|
|
const id = ev.id;
|
|
|
|
// Ocorrência virtual de série (id não é UUID válido)
|
|
if (!id || !isUuid(id)) {
|
|
info?.revert?.();
|
|
toast.add({ severity: 'info', summary: 'Sessão recorrente', detail: 'Para mover uma sessão da série, abra-a e edite com "Somente esta sessão".', life: 4500 });
|
|
return;
|
|
}
|
|
|
|
const startISO = ev.start ? ev.start.toISOString() : null;
|
|
const endISO = ev.end ? ev.end.toISOString() : null;
|
|
if (!startISO || !endISO) throw new Error('Evento sem start/end.');
|
|
|
|
// Validação de conflito: usar rows (inclui bloqueios) em vez de baseRows (pode filtrar por onlySessions)
|
|
const start = new Date(startISO);
|
|
const end = new Date(endISO);
|
|
const breakMin = settings.value?.session_break_min || 0;
|
|
const evtOwnerId = ev.extendedProps?.owner_id || null;
|
|
|
|
const conflict = (rows.value || []).find((r) => {
|
|
if (!r.inicio_em || r.id === id) return false;
|
|
if (evtOwnerId && r.owner_id && r.owner_id !== evtOwnerId) return false;
|
|
const rS = new Date(r.inicio_em);
|
|
const rE = new Date(r.fim_em || r.inicio_em);
|
|
const rEWithBreak = new Date(rE.getTime() + breakMin * 60000);
|
|
return start < rEWithBreak && end > rS;
|
|
});
|
|
|
|
if (conflict) {
|
|
info?.revert?.();
|
|
toast.add({ severity: 'warn', summary: 'Conflito', detail: 'Já existe um compromisso neste horário.', life: 4000 });
|
|
return;
|
|
}
|
|
|
|
await updateClinic(id, { inicio_em: startISO, fim_em: endISO }, { tenantId: tid });
|
|
|
|
// Patch local imediato para evitar que o re-render causado por loading.value=false
|
|
// passe os tempos antigos ao FullCalendar e reverta o drag visualmente
|
|
const rowIdx = rows.value.findIndex((r) => r.id === id);
|
|
if (rowIdx !== -1) {
|
|
const updated = { ...rows.value[rowIdx], inicio_em: startISO, fim_em: endISO };
|
|
rows.value = [...rows.value.slice(0, rowIdx), updated, ...rows.value.slice(rowIdx + 1)];
|
|
}
|
|
|
|
toast.add({ severity: 'success', summary: actionLabel, detail: 'Alteração salva.', life: 1800 });
|
|
} catch (e) {
|
|
info?.revert?.();
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar alteração.', life: 4500 });
|
|
}
|
|
}
|
|
|
|
function onEventDrop(payload) {
|
|
persistMoveOrResize(payload?.info || payload, 'Movido');
|
|
}
|
|
function onEventResize(payload) {
|
|
persistMoveOrResize(payload?.info || payload, 'Redimensionado');
|
|
}
|
|
|
|
// -------------------- create button --------------------
|
|
async function onCreateFromButton() {
|
|
const ok = await ensureCommitmentsLoaded();
|
|
if (!ok) return;
|
|
|
|
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50;
|
|
const now = new Date();
|
|
const viewDate = currentDate.value instanceof Date ? currentDate.value : new Date(currentDate.value);
|
|
const base = new Date(viewDate.getFullYear(), viewDate.getMonth(), viewDate.getDate(), now.getHours(), now.getMinutes(), 0, 0);
|
|
|
|
dialogEventRow.value = {
|
|
owner_id: clinicOwnerId.value,
|
|
terapeuta_id: null,
|
|
paciente_id: null,
|
|
tipo: EVENTO_TIPO.SESSAO,
|
|
status: 'agendado',
|
|
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
|
|
observacoes: null,
|
|
visibility_scope: 'public',
|
|
determined_commitment_id: null
|
|
};
|
|
dialogStartISO.value = base.toISOString();
|
|
dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
|
|
dialogOwnerId.value = clinicOwnerId.value;
|
|
dialogPresetCommitmentId.value = null;
|
|
dialogLockCommitment.value = false;
|
|
dialogOpen.value = true;
|
|
}
|
|
|
|
// -------------------- db field filter --------------------
|
|
function pickDbFields(obj) {
|
|
const allowed = [
|
|
'tenant_id',
|
|
'owner_id',
|
|
'terapeuta_id',
|
|
'patient_id',
|
|
'tipo',
|
|
'status',
|
|
'titulo',
|
|
'observacoes',
|
|
'inicio_em',
|
|
'fim_em',
|
|
'visibility_scope',
|
|
'mirror_of_event_id',
|
|
'mirror_source',
|
|
'determined_commitment_id',
|
|
'titulo_custom',
|
|
'extra_fields',
|
|
'modalidade',
|
|
// nova arquitetura
|
|
'recurrence_id',
|
|
'recurrence_date',
|
|
// financeiro
|
|
'price',
|
|
'insurance_plan_id',
|
|
'insurance_guide_number',
|
|
'insurance_value',
|
|
'insurance_plan_service_id'
|
|
];
|
|
const out = {};
|
|
for (const k of allowed) {
|
|
if (obj[k] !== undefined) out[k] = obj[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
|
const current = dialogEventRow.value || {};
|
|
dialogEventRow.value = {
|
|
...current,
|
|
id: id || null,
|
|
inicio_em,
|
|
fim_em,
|
|
recurrence_date,
|
|
_is_virtual: is_virtual
|
|
};
|
|
}
|
|
|
|
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
|
const tid = tenantId.value;
|
|
try {
|
|
if (id) {
|
|
await updateClinic(id, { status }, { tenantId: tid });
|
|
return;
|
|
}
|
|
if (!is_virtual || !inicio_em) return;
|
|
|
|
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
|
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
|
|
|
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
|
|
|
|
if (existing?.id) {
|
|
await updateClinic(existing.id, { status }, { tenantId: tid });
|
|
} else {
|
|
const row = dialogEventRow.value || {};
|
|
await createClinic(
|
|
{
|
|
owner_id: dialogOwnerId.value || clinicOwnerId.value,
|
|
tenant_id: tid,
|
|
recurrence_id: rid,
|
|
recurrence_date: rDate,
|
|
tipo: 'sessao',
|
|
status,
|
|
inicio_em,
|
|
fim_em,
|
|
visibility_scope: 'public',
|
|
titulo: row.titulo || 'Sessão',
|
|
patient_id: row.patient_id || row.paciente_id || null,
|
|
determined_commitment_id: row.determined_commitment_id || null
|
|
},
|
|
{ tenantId: tid }
|
|
);
|
|
}
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
|
|
}
|
|
}
|
|
|
|
// Opção C — oferece geração de billing_contract após criar série recorrente com serviços.
|
|
// Chamada APÓS fechar o dialog principal para não bloquear o fluxo principal.
|
|
async function _offerBillingContract(basePayload, recorrencia, tenantId) {
|
|
const n = recorrencia.qtdSessoes;
|
|
const items = recorrencia.commitmentItems || [];
|
|
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
|
|
const pacoteFechado = recorrencia.serieValorMode === 'dividir';
|
|
const packagePrice = pacoteFechado ? totalPorSessao : totalPorSessao * n;
|
|
const perSessao = pacoteFechado ? totalPorSessao / n : totalPorSessao;
|
|
const fmtB = (v) => Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
return new Promise((resolve) => {
|
|
confirm.require({
|
|
header: 'Gerar contrato de cobrança?',
|
|
message: `${n} sessões — ${fmtB(perSessao)} por sessão. Total da série: ${fmtB(packagePrice)}.`,
|
|
icon: 'pi pi-file',
|
|
acceptLabel: 'Sim, gerar contrato',
|
|
rejectLabel: 'Agora não',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await supabase.from('billing_contracts').insert({
|
|
owner_id: basePayload.owner_id,
|
|
tenant_id: tenantId,
|
|
patient_id: basePayload.paciente_id,
|
|
type: 'package',
|
|
total_sessions: n,
|
|
sessions_used: 0,
|
|
package_price: packagePrice,
|
|
status: 'active'
|
|
});
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Contrato gerado', detail: `Pacote de ${n} sessões: ${fmtB(packagePrice)}.`, life: 3000 });
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro ao gerar contrato', detail: e?.message, life: 4000 });
|
|
}
|
|
resolve();
|
|
},
|
|
reject: () => resolve()
|
|
});
|
|
});
|
|
}
|
|
|
|
async function onDialogSave(arg) {
|
|
const tid = tenantId.value;
|
|
if (!tid) {
|
|
toast.add({ severity: 'warn', summary: 'Tenant', detail: 'Selecione uma clínica antes de salvar.', life: 3500 });
|
|
return;
|
|
}
|
|
|
|
const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload');
|
|
const payload = isWrapped ? arg.payload : arg;
|
|
const recorrencia = arg?.recorrencia ?? null;
|
|
const editMode = arg?.editMode ?? null;
|
|
const recurrenceId = arg?.recurrence_id ?? arg?.serie_id ?? null;
|
|
const originalDate = arg?.original_date ?? dialogEventRow.value?.original_date ?? null;
|
|
const id = (isWrapped ? (arg.id ?? null) : (arg?.id ?? null)) || (dialogEventRow.value?.is_occurrence ? null : dialogEventRow.value?.id) || null;
|
|
|
|
try {
|
|
const ownerTarget = payload?.owner_id ?? dialogOwnerId.value ?? null;
|
|
if (!ownerTarget) {
|
|
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Selecione uma coluna antes de salvar.', life: 3500 });
|
|
return;
|
|
}
|
|
|
|
const clinicOwner = clinicOwnerId.value;
|
|
const basePayload = pickDbFields({
|
|
...payload,
|
|
owner_id: ownerTarget,
|
|
terapeuta_id: payload?.terapeuta_id ?? ownerTarget,
|
|
tenant_id: payload?.tenant_id ?? tid
|
|
});
|
|
|
|
// Validação: clínica só pode criar sessões para terapeutas
|
|
if (clinicOwner && ownerTarget !== clinicOwner) {
|
|
const commitmentId = basePayload.determined_commitment_id ?? null;
|
|
const c = commitmentId ? commitmentById.value.get(String(commitmentId)) : null;
|
|
const nk = String(c?.native_key || '').toLowerCase();
|
|
if (nk !== 'session') {
|
|
toast.add({ severity: 'warn', summary: 'Permissão', detail: 'A clínica só pode criar sessões para terapeutas.', life: 4500 });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ── CASO C / C2: criação RECORRENTE (novo ou evento existente) ─────────
|
|
// Só cria nova regra se NÃO há série existente — se houver recurrenceId, cai para F/G/E
|
|
if (recorrencia?.tipo === 'recorrente' && !recurrenceId) {
|
|
const startDate = new Date(basePayload.inicio_em);
|
|
const tipoFreq = recorrencia.tipoFreq ?? 'semanal';
|
|
const dow = recorrencia.diaSemana ?? startDate.getDay();
|
|
|
|
let ruleType = 'weekly';
|
|
let interval = 1;
|
|
let weekdays = [dow];
|
|
if (tipoFreq === 'quinzenal') {
|
|
ruleType = 'weekly';
|
|
interval = 2;
|
|
} else if (tipoFreq === 'diasEspecificos') {
|
|
ruleType = 'custom_weekdays';
|
|
weekdays = recorrencia.diasSemana?.length ? recorrencia.diasSemana : [dow];
|
|
}
|
|
|
|
const rule = {
|
|
tenant_id: tid,
|
|
owner_id: ownerTarget,
|
|
therapist_id: basePayload.terapeuta_id ?? null,
|
|
patient_id: basePayload.paciente_id ?? null,
|
|
determined_commitment_id: basePayload.determined_commitment_id ?? null,
|
|
type: ruleType,
|
|
interval,
|
|
weekdays,
|
|
start_time: recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 8),
|
|
end_time: _addMinutesToTime(recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 5), recorrencia.duracaoMin ?? 50),
|
|
duration_min: recorrencia.duracaoMin ?? 50,
|
|
timezone: settings.value?.timezone || 'America/Sao_Paulo',
|
|
start_date: basePayload.inicio_em?.slice(0, 10),
|
|
end_date: recorrencia.dataFim ? new Date(recorrencia.dataFim).toISOString().slice(0, 10) : null,
|
|
max_occurrences: recorrencia.qtdSessoes ?? null,
|
|
open_ended: !recorrencia.dataFim && !recorrencia.qtdSessoes,
|
|
modalidade: basePayload.modalidade ?? 'presencial',
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null,
|
|
price: basePayload.price ?? null,
|
|
insurance_plan_id: basePayload.insurance_plan_id ?? null,
|
|
insurance_guide_number: basePayload.insurance_guide_number ?? null,
|
|
insurance_value: basePayload.insurance_value ?? null,
|
|
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null,
|
|
status: 'ativo'
|
|
};
|
|
const createdRule = await createRule(rule);
|
|
|
|
// Se editando evento existente, vincula à nova regra
|
|
if (id && createdRule?.id) {
|
|
const firstRecISO = basePayload.inicio_em?.slice(0, 10);
|
|
await updateClinic(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO }, { tenantId: tid });
|
|
}
|
|
|
|
// Opção C — salvar template de serviços da regra
|
|
if (createdRule?.id && recorrencia.commitmentItems?.length) {
|
|
await saveRuleItems(createdRule.id, recorrencia.commitmentItems);
|
|
}
|
|
|
|
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
|
|
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
|
|
// Opção C — oferecer billing_contract após fechar o dialog (só com serviços + nº definido + paciente)
|
|
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && basePayload.paciente_id) {
|
|
await _offerBillingContract(basePayload, recorrencia, tid);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// ── CASO D: edição "somente_este" ──────────────────────────────────────
|
|
if (recurrenceId && editMode === 'somente_este') {
|
|
let eventId = id ?? null;
|
|
|
|
if (id) {
|
|
// Evento já materializado: atualiza campos + mantém exceção sincronizada
|
|
await updateClinic(id, basePayload, { tenantId: tid });
|
|
if (originalDate) {
|
|
await upsertException({
|
|
recurrence_id: recurrenceId,
|
|
tenant_id: tid,
|
|
original_date: originalDate,
|
|
type: 'reschedule_session',
|
|
new_date: basePayload.inicio_em?.slice(0, 10),
|
|
new_start_time: basePayload.inicio_em ? new Date(basePayload.inicio_em).toTimeString().slice(0, 8) : null,
|
|
new_end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : null,
|
|
modalidade: basePayload.modalidade ?? null,
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null
|
|
});
|
|
}
|
|
} else if (originalDate) {
|
|
// Ocorrência ainda virtual: cria exceção + materializa para salvar commitment_services
|
|
await upsertException({
|
|
recurrence_id: recurrenceId,
|
|
tenant_id: tid,
|
|
original_date: originalDate,
|
|
type: 'reschedule_session',
|
|
new_date: basePayload.inicio_em?.slice(0, 10),
|
|
new_start_time: basePayload.inicio_em ? new Date(basePayload.inicio_em).toTimeString().slice(0, 8) : null,
|
|
new_end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : null,
|
|
modalidade: basePayload.modalidade ?? null,
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null
|
|
});
|
|
if (arg.onSaved) {
|
|
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
|
|
if (existing?.id) {
|
|
eventId = existing.id;
|
|
} else {
|
|
const mat = await createClinic(
|
|
{
|
|
owner_id: basePayload.owner_id,
|
|
tenant_id: tid,
|
|
recurrence_id: recurrenceId,
|
|
recurrence_date: originalDate,
|
|
tipo: basePayload.tipo,
|
|
status: basePayload.status,
|
|
inicio_em: basePayload.inicio_em,
|
|
fim_em: basePayload.fim_em,
|
|
titulo: basePayload.titulo,
|
|
patient_id: basePayload.patient_id,
|
|
determined_commitment_id: basePayload.determined_commitment_id,
|
|
modalidade: basePayload.modalidade ?? 'presencial',
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null,
|
|
price: basePayload.price ?? null,
|
|
insurance_plan_id: basePayload.insurance_plan_id ?? null,
|
|
insurance_guide_number: basePayload.insurance_guide_number ?? null,
|
|
insurance_value: basePayload.insurance_value ?? null,
|
|
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null
|
|
},
|
|
{ tenantId: tid }
|
|
);
|
|
eventId = mat.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Opção C — salvar serviços e marcar esta ocorrência como customizada
|
|
if (eventId) await arg.onSaved?.(eventId, { markCustomized: true });
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO E: edição "este e os seguintes" ───────────────────────────────
|
|
if (recurrenceId && editMode === 'este_e_seguintes' && originalDate) {
|
|
const newRuleId = await splitRuleAt(recurrenceId, originalDate);
|
|
const startDate = new Date(basePayload.inicio_em);
|
|
await updateRule(newRuleId, {
|
|
weekdays: [startDate.getDay()],
|
|
start_time: startDate.toTimeString().slice(0, 8),
|
|
end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : undefined,
|
|
duration_min: recorrencia?.duracaoMin ?? 50,
|
|
modalidade: basePayload.modalidade ?? 'presencial',
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null,
|
|
price: basePayload.price ?? null,
|
|
insurance_plan_id: basePayload.insurance_plan_id ?? null,
|
|
insurance_guide_number: basePayload.insurance_guide_number ?? null,
|
|
insurance_value: basePayload.insurance_value ?? null,
|
|
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null
|
|
});
|
|
|
|
// Opção C — atualizar template e propagar para a nova sub-série
|
|
const serviceItemsE = arg.serviceItems;
|
|
if (newRuleId && serviceItemsE?.length) {
|
|
await saveRuleItems(newRuleId, serviceItemsE);
|
|
await propagateToSerie(newRuleId, serviceItemsE, { fromDate: originalDate });
|
|
}
|
|
if (id) await arg.onSaved?.(id);
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Esta sessão e as seguintes foram atualizadas.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO F: edição "todos" ─────────────────────────────────────────────
|
|
if (recurrenceId && editMode === 'todos') {
|
|
const startDate = new Date(basePayload.inicio_em);
|
|
await updateRule(recurrenceId, {
|
|
weekdays: [startDate.getDay()],
|
|
start_time: startDate.toTimeString().slice(0, 8),
|
|
end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : undefined,
|
|
duration_min: recorrencia?.duracaoMin ?? 50,
|
|
modalidade: basePayload.modalidade ?? 'presencial',
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null,
|
|
price: basePayload.price ?? null,
|
|
insurance_plan_id: basePayload.insurance_plan_id ?? null,
|
|
insurance_guide_number: basePayload.insurance_guide_number ?? null,
|
|
insurance_value: basePayload.insurance_value ?? null,
|
|
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null
|
|
});
|
|
|
|
// Propaga campos não-serviço para sessões já materializadas da série
|
|
await supabase
|
|
.from('agenda_eventos')
|
|
.update({
|
|
modalidade: basePayload.modalidade ?? 'presencial',
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null,
|
|
price: basePayload.price ?? null,
|
|
insurance_plan_id: basePayload.insurance_plan_id ?? null,
|
|
insurance_guide_number: basePayload.insurance_guide_number ?? null,
|
|
insurance_value: basePayload.insurance_value ?? null,
|
|
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null
|
|
})
|
|
.eq('recurrence_id', recurrenceId);
|
|
|
|
// Opção C — atualizar template e propagar para toda a série
|
|
const serviceItemsF = arg.serviceItems;
|
|
if (recurrenceId && serviceItemsF?.length) {
|
|
await saveRuleItems(recurrenceId, serviceItemsF);
|
|
await propagateToSerie(recurrenceId, serviceItemsF);
|
|
}
|
|
if (id) await arg.onSaved?.(id);
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO G: edição "todos sem exceção" — sobrescreve TUDO incluindo customizadas ──
|
|
if (recurrenceId && editMode === 'todos_sem_excecao') {
|
|
const startDate = new Date(basePayload.inicio_em);
|
|
await updateRule(recurrenceId, {
|
|
weekdays: [startDate.getDay()],
|
|
start_time: startDate.toTimeString().slice(0, 8),
|
|
end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : undefined,
|
|
duration_min: recorrencia?.duracaoMin ?? 50,
|
|
modalidade: basePayload.modalidade ?? 'presencial',
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null,
|
|
price: basePayload.price ?? null,
|
|
insurance_plan_id: basePayload.insurance_plan_id ?? null,
|
|
insurance_guide_number: basePayload.insurance_guide_number ?? null,
|
|
insurance_value: basePayload.insurance_value ?? null,
|
|
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null
|
|
});
|
|
|
|
// Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção)
|
|
await supabase
|
|
.from('agenda_eventos')
|
|
.update({
|
|
modalidade: basePayload.modalidade ?? 'presencial',
|
|
titulo_custom: basePayload.titulo_custom ?? null,
|
|
observacoes: basePayload.observacoes ?? null,
|
|
extra_fields: basePayload.extra_fields ?? null,
|
|
price: basePayload.price ?? null,
|
|
insurance_plan_id: basePayload.insurance_plan_id ?? null,
|
|
insurance_guide_number: basePayload.insurance_guide_number ?? null,
|
|
insurance_value: basePayload.insurance_value ?? null,
|
|
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null,
|
|
services_customized: false
|
|
})
|
|
.eq('recurrence_id', recurrenceId);
|
|
|
|
// Propaga para todos — incluindo services_customized=true — e reseta o flag
|
|
const serviceItemsG = arg.serviceItems;
|
|
if (recurrenceId && serviceItemsG?.length) {
|
|
await saveRuleItems(recurrenceId, serviceItemsG);
|
|
await propagateToSerie(recurrenceId, serviceItemsG, { ignoreCustomized: true });
|
|
}
|
|
|
|
if (id) await arg.onSaved?.(id);
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas (sem exceção).', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO A/B: evento avulso ou sessão única ────────────────────────────
|
|
if (id) {
|
|
await updateClinic(id, basePayload, { tenantId: tid });
|
|
await arg.onSaved?.(id);
|
|
} else {
|
|
const created = await createClinic(basePayload, { tenantId: tid });
|
|
await arg.onSaved?.(created.id);
|
|
}
|
|
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
if (eventsError.value) {
|
|
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 });
|
|
} else {
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento salvo.', life: 2500 });
|
|
}
|
|
} catch (e) {
|
|
const msg = String(e?.message || '');
|
|
if (msg.includes('recurrence_rules_dates_chk') || (msg.includes('violates check constraint') && msg.includes('recurrence_rules'))) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Não foi possível dividir a série',
|
|
detail: 'Esta é a primeira sessão da série. Para alterar todas as ocorrências, selecione "Todos" ou "Todos sem exceção".',
|
|
life: 6000
|
|
});
|
|
return;
|
|
}
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar.', life: 4500 });
|
|
}
|
|
}
|
|
|
|
async function onDialogDelete(arg) {
|
|
const tid = tenantId.value;
|
|
if (!tid) {
|
|
toast.add({ severity: 'warn', summary: 'Tenant', detail: 'Selecione uma clínica antes de excluir.', life: 3500 });
|
|
return;
|
|
}
|
|
|
|
const id = typeof arg === 'string' ? arg : arg?.id;
|
|
const editMode = typeof arg === 'string' ? null : arg?.editMode;
|
|
const recurrenceId = typeof arg === 'string' ? null : (arg?.recurrence_id ?? arg?.serie_id ?? null);
|
|
const originalDate = typeof arg === 'string' ? null : (arg?.original_date ?? dialogEventRow.value?.original_date ?? null);
|
|
|
|
try {
|
|
// ── Somente este evento / ocorrência ──────────────────────────────────
|
|
if (!recurrenceId || editMode === 'somente_este') {
|
|
if (originalDate && recurrenceId) {
|
|
await upsertException({
|
|
recurrence_id: recurrenceId,
|
|
tenant_id: tid,
|
|
original_date: originalDate,
|
|
type: 'cancel_session'
|
|
});
|
|
} else if (id) {
|
|
await removeClinic(id, { tenantId: tid });
|
|
}
|
|
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Sessão removida.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── Este e os seguintes ───────────────────────────────────────────────
|
|
if (editMode === 'este_e_seguintes' && originalDate) {
|
|
await cancelRuleFrom(recurrenceId, originalDate);
|
|
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Esta sessão e as seguintes foram canceladas.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── Todos (encerrar série, manter sessão atual como avulsa) ──────────────
|
|
if (editMode === 'todos') {
|
|
const row = dialogEventRow.value || {};
|
|
const isVirtual = row.is_occurrence && !id;
|
|
|
|
if (isVirtual) {
|
|
const rDate = row.original_date || row.inicio_em?.slice(0, 10);
|
|
const existing = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
|
|
|
|
if (existing.data?.id) {
|
|
await updateClinic(existing.data.id, { recurrence_id: null, recurrence_date: null }, { tenantId: tid });
|
|
} else {
|
|
await createClinic(
|
|
{
|
|
owner_id: row.owner_id,
|
|
tenant_id: tid,
|
|
tipo: row.tipo || 'sessao',
|
|
status: row.status || 'agendado',
|
|
inicio_em: row.inicio_em,
|
|
fim_em: row.fim_em,
|
|
titulo: row.titulo || 'Sessão',
|
|
patient_id: row.patient_id || row.paciente_id || null,
|
|
determined_commitment_id: row.determined_commitment_id || null,
|
|
modalidade: row.modalidade || 'presencial',
|
|
price: row.price ?? null,
|
|
observacoes: row.observacoes || null,
|
|
visibility_scope: 'public'
|
|
},
|
|
{ tenantId: tid }
|
|
);
|
|
}
|
|
} else if (id) {
|
|
await updateClinic(id, { recurrence_id: null, recurrence_date: null }, { tenantId: tid });
|
|
}
|
|
|
|
await cancelRule(recurrenceId);
|
|
toast.add({ severity: 'success', summary: 'Série encerrada', detail: 'A série foi encerrada. Esta sessão foi mantida como avulsa.', life: 3000 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// fallback
|
|
if (id) await removeClinic(id, { tenantId: tid });
|
|
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao excluir.', life: 4500 });
|
|
}
|
|
}
|
|
|
|
async function _reloadRange() {
|
|
const tid = tenantId.value;
|
|
if (!tid || !currentRange.value.start || !currentRange.value.end) return;
|
|
const start = new Date(currentRange.value.start);
|
|
const end = new Date(currentRange.value.end);
|
|
|
|
await loadClinicRange({
|
|
tenantId: tid,
|
|
ownerIds: ownerIds.value,
|
|
startISO: start.toISOString(),
|
|
endISO: end.toISOString()
|
|
});
|
|
|
|
// Expande recorrências para cada terapeuta no range
|
|
const allMerged = [];
|
|
for (const ownId of ownerIds.value) {
|
|
const merged = await loadAndExpand(
|
|
ownId,
|
|
start,
|
|
end,
|
|
rows.value.filter((r) => r.owner_id === ownId),
|
|
tenantId.value
|
|
);
|
|
allMerged.push(...merged.filter((r) => r.is_occurrence));
|
|
}
|
|
_occurrenceRows.value = allMerged;
|
|
}
|
|
|
|
// Ocorrências virtuais geradas pelo useRecurrence
|
|
const _occurrenceRows = ref([]);
|
|
|
|
// -------------------- search helpers --------------------
|
|
function clearSearch() {
|
|
search.value = '';
|
|
}
|
|
function clearSearchAndClose() {
|
|
search.value = '';
|
|
searchModalOpen.value = false;
|
|
}
|
|
function openSearchModal() {
|
|
if (searchTrim.value) searchModalOpen.value = true;
|
|
}
|
|
|
|
function gotoResult(row) {
|
|
if (row?.inicio_em) {
|
|
const safe = normalizeDay(new Date(row.inicio_em));
|
|
calendarRef.value?.gotoDate?.(safe);
|
|
}
|
|
|
|
dialogEventRow.value = row;
|
|
dialogOwnerId.value = row?.owner_id || row?.terapeuta_id || clinicOwnerId.value;
|
|
dialogStartISO.value = '';
|
|
dialogEndISO.value = '';
|
|
dialogPresetCommitmentId.value = null;
|
|
dialogLockCommitment.value = false;
|
|
dialogOpen.value = true;
|
|
}
|
|
|
|
function gotoResultFromModal(row) {
|
|
searchModalOpen.value = false;
|
|
nextTick(() => gotoResult(row));
|
|
}
|
|
|
|
function fmtDateTime(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
|
}
|
|
function fmtDateOnly(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
|
}
|
|
function fmtTimeOnly(iso) {
|
|
if (!iso) return '';
|
|
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function labelTipo(tipo) {
|
|
const t = String(tipo || '').toLowerCase();
|
|
if (t.includes('sess')) return 'Sessão';
|
|
if (t.includes('bloq')) return 'Bloqueio';
|
|
if (t.includes('avali')) return 'Avaliação';
|
|
return tipo || 'Evento';
|
|
}
|
|
|
|
// -------------------- month picker + mini calendar --------------------
|
|
function toggleMonthPicker() {
|
|
monthPickerDate.value = new Date(currentDate.value);
|
|
monthPickerVisible.value = true;
|
|
}
|
|
|
|
async function applyMonthPick() {
|
|
monthPickerVisible.value = false;
|
|
await nextTick();
|
|
const safe = normalizeDay(monthPickerDate.value);
|
|
calendarRef.value?.gotoDate?.(safe);
|
|
}
|
|
|
|
function miniGoToday() {
|
|
const safe = normalizeDay(new Date());
|
|
miniDate.value = safe;
|
|
calendarRef.value?.gotoDate?.(safe);
|
|
}
|
|
function shiftMonth(date, delta) {
|
|
const d = new Date(date);
|
|
d.setMonth(d.getMonth() + delta);
|
|
return d;
|
|
}
|
|
function miniPrevMonth() {
|
|
miniDate.value = shiftMonth(miniDate.value, -1);
|
|
}
|
|
function miniNextMonth() {
|
|
miniDate.value = shiftMonth(miniDate.value, +1);
|
|
}
|
|
|
|
const workDowSet = computed(() => new Set(workRules.value.filter((r) => r.ativo).map((r) => Number(r.dia_semana))));
|
|
|
|
// ── Mini calendário: set de dias da semana atual ─────────────
|
|
const currentWeekIsoSet = computed(() => {
|
|
const now = new Date();
|
|
const monday = new Date(now);
|
|
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
|
|
monday.setHours(0, 0, 0, 0);
|
|
const set = new Set();
|
|
for (let i = 0; i < 7; i++) {
|
|
const d = new Date(monday);
|
|
d.setDate(monday.getDate() + i);
|
|
set.add(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
|
}
|
|
return set;
|
|
});
|
|
|
|
const todayISO = computed(() => {
|
|
const n = new Date();
|
|
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
|
|
});
|
|
|
|
// ── Mini calendário: classes por dia ──────────────────────────
|
|
function miniDayClass(date) {
|
|
const iso = `${date.year}-${String(date.month + 1).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
|
|
const dow = new Date(date.year, date.month, date.day).getDay();
|
|
const classes = [];
|
|
if (currentWeekIsoSet.value.has(iso)) {
|
|
classes.push('mini-week-hl');
|
|
if (dow === 1) classes.push('mini-week-hl--start');
|
|
else if (dow === 0) classes.push('mini-week-hl--end');
|
|
else classes.push('mini-week-hl--mid');
|
|
}
|
|
if (iso === todayISO.value) classes.push('mini-day-today');
|
|
if (miniBlockedDaySet.value.has(iso)) classes.push('mini-day-blocked');
|
|
else classes.push(workDowSet.value.has(dow) ? 'mini-day-work' : 'mini-day-off');
|
|
return classes;
|
|
}
|
|
|
|
// ── Mini calendário: bolinhas + bloqueios de dia inteiro ──────
|
|
const miniEventDaySet = ref(new Set());
|
|
const miniBlockedDaySet = ref(new Set());
|
|
const miniBlockedLoaded = ref(false);
|
|
|
|
async function loadMiniMonthEvents(refDate) {
|
|
if (!clinicOwnerId.value) return; // aguarda settings carregarem — evita 400 com owner_id vazio
|
|
const d = refDate instanceof Date ? refDate : new Date(refDate);
|
|
const year = d.getFullYear();
|
|
const month = d.getMonth();
|
|
const start = new Date(year, month, 1);
|
|
const end = new Date(year, month + 1, 0, 23, 59, 59);
|
|
|
|
try {
|
|
const tid = tenantId.value;
|
|
// 1. Eventos normais (bolinhas)
|
|
let evQ = supabase.from('agenda_eventos').select('inicio_em').gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
|
|
if (tid) evQ = evQ.eq('tenant_id', tid);
|
|
const { data: evData } = await evQ;
|
|
|
|
const evSet = new Set();
|
|
for (const r of evData || []) {
|
|
if (!r.inicio_em) continue;
|
|
const ev = new Date(r.inicio_em);
|
|
evSet.add(`${ev.getFullYear()}-${ev.getMonth()}-${ev.getDate()}`);
|
|
}
|
|
|
|
// 2. Ocorrências virtuais de recorrência (não existem no banco)
|
|
for (const oid of ownerIds.value || []) {
|
|
const occRows = await loadAndExpand(
|
|
oid,
|
|
start,
|
|
end,
|
|
rows.value.filter((r) => r.owner_id === oid),
|
|
tenantId.value
|
|
);
|
|
for (const r of occRows || []) {
|
|
if (!r.inicio_em || !r.is_occurrence) continue;
|
|
const ev = new Date(r.inicio_em);
|
|
evSet.add(`${ev.getFullYear()}-${ev.getMonth()}-${ev.getDate()}`);
|
|
}
|
|
}
|
|
|
|
miniEventDaySet.value = evSet;
|
|
|
|
// 3. Bloqueios de dia inteiro (fundo vermelho forte)
|
|
// Usa ISO local para evitar off-by-one em fusos negativos como -03:00
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
const isoStart = `${year}-${pad(month + 1)}-01`;
|
|
const lastDay = new Date(year, month + 1, 0).getDate();
|
|
const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`;
|
|
let blkQ = supabase.from('agenda_bloqueios').select('data_inicio').is('hora_inicio', null).gte('data_inicio', isoStart).lte('data_inicio', isoEnd);
|
|
if (clinicOwnerId.value) blkQ = blkQ.eq('owner_id', clinicOwnerId.value);
|
|
const { data: blkData } = await blkQ;
|
|
miniBlockedDaySet.value = new Set((blkData || []).map((r) => r.data_inicio));
|
|
miniBlockedLoaded.value = true;
|
|
} catch {
|
|
/* silencioso */
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => {
|
|
const d = miniDate.value instanceof Date ? miniDate.value : new Date(miniDate.value);
|
|
return `${d.getFullYear()}-${d.getMonth()}`;
|
|
},
|
|
() => loadMiniMonthEvents(miniDate.value),
|
|
{ immediate: true }
|
|
);
|
|
watch(rows, () => loadMiniMonthEvents(miniDate.value));
|
|
// Fix persistência: recarrega quando clinicOwnerId fica disponível (settings são async)
|
|
watch(clinicOwnerId, (v) => {
|
|
if (v) loadMiniMonthEvents(miniDate.value);
|
|
});
|
|
|
|
function hasMiniEvent(date) {
|
|
return miniEventDaySet.value.has(`${date.year}-${date.month}-${date.day}`);
|
|
}
|
|
|
|
// ── Feriados próximos (30 dias) em dias úteis SEM bloqueio — inclui hoje e amanhã ──
|
|
const feriadosSemBloqueio = computed(() => {
|
|
if (!miniBlockedLoaded.value) return [];
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
const limite = new Date(hoje);
|
|
limite.setDate(limite.getDate() + 30);
|
|
const bloqueados = miniBlockedDaySet.value;
|
|
|
|
return (feriadoFcEvents.value || [])
|
|
.filter((f) => {
|
|
const iso = f.startStr || String(f.start || '').slice(0, 10);
|
|
if (!iso) return false;
|
|
const [y, m, da] = iso.split('-').map(Number);
|
|
const dt = new Date(y, m - 1, da);
|
|
if (dt < hoje || dt > limite) return false;
|
|
if (!workDowSet.value.has(dt.getDay())) return false;
|
|
return !bloqueados.has(iso);
|
|
})
|
|
.map((f) => ({ data: f.startStr || String(f.start || '').slice(0, 10), nome: f.title || 'Feriado' }));
|
|
});
|
|
|
|
// ── Todos os feriados próximos em dias úteis (bloqueados + pendentes) para o dialog ──
|
|
const feriadosTodosProximos = computed(() => {
|
|
if (!miniBlockedLoaded.value) return [];
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
const limite = new Date(hoje);
|
|
limite.setDate(limite.getDate() + 30);
|
|
const bloqueados = miniBlockedDaySet.value;
|
|
|
|
return (feriadoFcEvents.value || [])
|
|
.filter((f) => {
|
|
const iso = f.startStr || String(f.start || '').slice(0, 10);
|
|
if (!iso) return false;
|
|
const [y, m, da] = iso.split('-').map(Number);
|
|
const dt = new Date(y, m - 1, da);
|
|
if (dt < hoje || dt > limite) return false;
|
|
return workDowSet.value.has(dt.getDay());
|
|
})
|
|
.map((f) => {
|
|
const iso = f.startStr || String(f.start || '').slice(0, 10);
|
|
return { data: iso, nome: f.title || 'Feriado', bloqueado: bloqueados.has(iso) };
|
|
});
|
|
});
|
|
|
|
// ── Dialog de alerta de feriados ─────────────────────────────
|
|
const feriadosAlertaOpen = ref(false);
|
|
const feriadosAlertaBloqueados = ref(new Set());
|
|
const feriadosAlertaSalvando = ref(null);
|
|
|
|
function fmtFeriadoDateLong(iso) {
|
|
if (!iso) return '';
|
|
const [y, m, d] = iso.split('-').map(Number);
|
|
return new Date(y, m - 1, d).toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
|
|
}
|
|
|
|
async function bloquearFeriadoDoAlerta(feriado) {
|
|
if (!clinicOwnerId.value || !tenantId.value) return;
|
|
feriadosAlertaSalvando.value = feriado.data;
|
|
try {
|
|
const { error } = await supabase.from('agenda_bloqueios').insert([
|
|
{
|
|
owner_id: clinicOwnerId.value,
|
|
tenant_id: tenantId.value,
|
|
tipo: 'bloqueio',
|
|
recorrente: false,
|
|
titulo: `Feriado: ${feriado.nome}`,
|
|
data_inicio: feriado.data,
|
|
data_fim: feriado.data,
|
|
hora_inicio: null,
|
|
hora_fim: null,
|
|
origem: 'agenda_feriado'
|
|
}
|
|
]);
|
|
if (error) throw error;
|
|
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
|
|
|
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
|
|
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
|
|
toast.add({ severity: 'success', summary: 'Bloqueado', detail: `${feriado.nome} bloqueado.`, life: 3000 });
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
} finally {
|
|
feriadosAlertaSalvando.value = null;
|
|
}
|
|
}
|
|
|
|
async function bloquearTodosFeriadosAlerta() {
|
|
feriadosAlertaSalvando.value = 'all';
|
|
const pendentes = feriadosSemBloqueio.value.filter((f) => !feriadosAlertaBloqueados.value.has(f.data));
|
|
for (const f of pendentes) await bloquearFeriadoDoAlerta(f);
|
|
feriadosAlertaSalvando.value = null;
|
|
}
|
|
|
|
async function desbloquearFeriadoDoAlerta(feriado) {
|
|
if (!clinicOwnerId.value) return;
|
|
feriadosAlertaSalvando.value = `unblock_${feriado.data}`;
|
|
try {
|
|
const { error } = await supabase.from('agenda_bloqueios').delete().eq('owner_id', clinicOwnerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
|
|
|
|
if (error) throw error;
|
|
|
|
const novo = new Set(miniBlockedDaySet.value);
|
|
novo.delete(feriado.data);
|
|
miniBlockedDaySet.value = novo;
|
|
|
|
const novoBloq = new Set(feriadosAlertaBloqueados.value);
|
|
novoBloq.delete(feriado.data);
|
|
feriadosAlertaBloqueados.value = novoBloq;
|
|
|
|
toast.add({
|
|
severity: 'info',
|
|
summary: 'Bloqueio removido',
|
|
detail: `O bloqueio de "${feriado.nome}" foi removido. Sessões marcadas para reagendamento precisam ser reativadas manualmente.`,
|
|
life: 5000
|
|
});
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
} finally {
|
|
feriadosAlertaSalvando.value = null;
|
|
}
|
|
}
|
|
|
|
function onMiniPick(d) {
|
|
if (!d) return;
|
|
const safe = normalizeDay(d);
|
|
miniDate.value = safe;
|
|
calendarRef.value?.gotoDate?.(safe);
|
|
}
|
|
|
|
const restrictPatientsToOwner = computed(() => {
|
|
const owner = String(dialogOwnerId.value || '').trim();
|
|
const clinic = String(clinicOwnerId.value || '').trim();
|
|
return !!(owner && clinic && owner !== clinic);
|
|
});
|
|
|
|
const patientScopeOwnerId = computed(() => {
|
|
if (!restrictPatientsToOwner.value) return null;
|
|
const userId = String(dialogOwnerId.value || '').trim();
|
|
return tenantMemberIdByUserId.value.get(userId) || null;
|
|
});
|
|
|
|
// ── helper privado ─────────────────────────────────────────────────────────
|
|
function _addMinutesToTime(timeStr, minutes) {
|
|
const [h, m] = String(timeStr || '09:00')
|
|
.split(':')
|
|
.map(Number);
|
|
const total = h * 60 + m + Number(minutes || 0);
|
|
const nh = Math.floor(total / 60) % 24;
|
|
const nm = total % 60;
|
|
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}:00`;
|
|
}
|
|
|
|
function refetch() {
|
|
const tid = tenantId.value;
|
|
if (!tid) return;
|
|
if (currentRange.value.start && currentRange.value.end && ownerIds.value.length) {
|
|
loadClinicRange({
|
|
tenantId: tid,
|
|
ownerIds: ownerIds.value,
|
|
startISO: new Date(currentRange.value.start).toISOString(),
|
|
endISO: new Date(currentRange.value.end).toISOString()
|
|
});
|
|
}
|
|
toast.add({ severity: 'info', summary: 'Agenda', detail: 'Recarregando…', life: 1500 });
|
|
}
|
|
|
|
function goSettings() {
|
|
router.push({ path: '/configuracoes/agenda' });
|
|
}
|
|
function goRecorrencias() {
|
|
router.push({ name: 'admin-agenda-recorrencias' });
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<ConfirmDialog />
|
|
|
|
<!-- Sentinel -->
|
|
<div ref="headerSentinelRef" class="ag-sentinel" />
|
|
|
|
<!-- Topbar compacta sticky -->
|
|
<div ref="headerEl" class="ag-topbar my-3 md:mx-4" :class="{ 'ag-topbar--stuck': headerStuck }">
|
|
<div class="ag-topbar__blobs" aria-hidden="true">
|
|
<div class="ag-topbar__blob ag-topbar__blob--1" />
|
|
<div class="ag-topbar__blob ag-topbar__blob--2" />
|
|
</div>
|
|
<div class="ag-topbar__inner">
|
|
<!-- Brand -->
|
|
<div class="ag-topbar__brand">
|
|
<div class="ag-topbar__icon"><i class="pi pi-calendar text-base" /></div>
|
|
<div class="min-w-0 hidden xl:block">
|
|
<div class="ag-topbar__title">Agenda · Clínica</div>
|
|
<div class="ag-topbar__sub">{{ subtitleText }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navegação -->
|
|
<div class="ag-topbar__nav">
|
|
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full hidden lg:flex" @click="goToday" />
|
|
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
|
|
<span class="ag-topbar__date-pill" @click="toggleMonthPicker">
|
|
<i class="pi pi-calendar text-xs opacity-60" />
|
|
{{ subtitleText }}
|
|
</span>
|
|
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
|
|
</div>
|
|
|
|
<!-- Filtros (desktop) -->
|
|
<div class="ag-topbar__filters hidden xl:flex items-center gap-1.5">
|
|
<SelectButton v-model="calendarView" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
|
|
<SelectButton v-model="timeMode" :options="timeModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
|
|
<SelectButton v-model="onlySessions" :options="onlySessionsOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
|
|
<SelectButton v-model="mosaicMode" :options="mosaicModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
|
|
</div>
|
|
|
|
<!-- Ações -->
|
|
<div class="ag-topbar__actions">
|
|
<!-- Busca desktop -->
|
|
<div class="hidden xl:block w-44">
|
|
<FloatLabel variant="on">
|
|
<IconField>
|
|
<InputIcon class="pi pi-search" />
|
|
<InputText id="agendaSearch" v-model="search" class="w-full" autocomplete="off" @keyup.enter="openSearchModal" />
|
|
</IconField>
|
|
<label for="agendaSearch">Buscar...</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<!-- Sino feriados -->
|
|
<div v-if="feriadosTodosProximos.length" class="relative">
|
|
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
|
|
<span v-if="feriadosSemBloqueio.length" class="ag-badge">{{ feriadosSemBloqueio.length }}</span>
|
|
</div>
|
|
|
|
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Novo compromisso" @click="onCreateFromButton" />
|
|
|
|
<!-- Mobile -->
|
|
<div class="flex xl:hidden items-center gap-1">
|
|
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchModalOpen = true" />
|
|
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => headerMenuRef.toggle(e)" />
|
|
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
|
|
</div>
|
|
|
|
<!-- Desktop: extras -->
|
|
<div class="hidden xl:flex items-center gap-1">
|
|
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
|
|
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recarregar'" @click="refetch" />
|
|
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recorrências'" @click="goRecorrencias" />
|
|
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Aviso: fora da jornada -->
|
|
<div
|
|
v-if="hasEventsOutsideWorkHours"
|
|
class="my-3 md:mx-4 rounded-[6px] p-3"
|
|
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent)"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<i class="pi pi-exclamation-triangle shrink-0" style="color: var(--yellow-600, #ca8a04)" />
|
|
<div class="font-semibold text-sm flex-1">Compromissos fora da jornada</div>
|
|
<div class="flex gap-1 shrink-0">
|
|
<Button label="Meu Horário" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = 'my'" />
|
|
<Button label="12h" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = '12'" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Layout 2 colunas: calendário + sidebar -->
|
|
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
|
|
<!-- Col centro: calendário mosaic -->
|
|
<div class="w-full xl:flex-1 min-w-0">
|
|
<div class="ag-cal-wrap">
|
|
<div class="p-2">
|
|
<AgendaClinicMosaic
|
|
ref="calendarRef"
|
|
:view="calendarView"
|
|
:mode="mosaicMode"
|
|
:slotMinTime="slotMinTime"
|
|
:slotMaxTime="slotMaxTime"
|
|
:slotDuration="slotDuration"
|
|
:expandRows="false"
|
|
:businessHours="businessHours"
|
|
:staff="staffCols"
|
|
:events="allEvents"
|
|
:loading="loadingStaff || loadingEvents"
|
|
:showClinicColumn="true"
|
|
:clinicId="clinicOwnerId"
|
|
clinicTitle="Clínica"
|
|
clinicSubtitle="Agenda da clínica"
|
|
:blockedDates="[...miniBlockedDaySet]"
|
|
@rangeChange="onRangeChange"
|
|
@slotSelect="onSlotSelect"
|
|
@eventClick="onEventClick"
|
|
@eventDrop="onEventDrop"
|
|
@eventResize="onEventResize"
|
|
@debugColumn="onDebugColumn"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[280px] shrink-0">
|
|
<!-- Resultados -->
|
|
<div v-if="searchTrim" class="ag-card">
|
|
<div class="mb-2 flex items-center justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<div class="font-semibold truncate">Resultados</div>
|
|
<small class="text-color-secondary truncate">para "{{ searchTrim }}"</small>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<Tag :value="`${searchResults.length}`" severity="secondary" />
|
|
<Button icon="pi pi-times" severity="secondary" text class="h-9 w-9 rounded-full" v-tooltip.top="'Limpar busca'" @click="clearSearch" />
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="searchResults.length === 0" class="text-color-secondary text-sm">Nenhum resultado encontrado.</div>
|
|
|
|
<div v-else class="flex flex-col gap-2 max-h-[360px] overflow-auto pr-1">
|
|
<button v-for="r in searchResults" :key="r.id" class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)]/40 p-3 transition hover:shadow-sm" @click="gotoResult(r)">
|
|
<div class="font-medium truncate">{{ r.titulo || 'Sem título' }}</div>
|
|
<span v-if="r.patients?.status === 'Inativo' || r.patients?.status === 'Arquivado'" class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5">{{
|
|
r.patients?.status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'
|
|
}}</span>
|
|
|
|
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
|
|
<span class="truncate">{{ fmtDateTime(r.inicio_em) }}</span>
|
|
<Tag :value="labelTipo(r.tipo)" severity="info" />
|
|
</div>
|
|
|
|
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate">
|
|
{{ r.observacoes }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mini calendário -->
|
|
<div class="ag-card">
|
|
<div class="ag-card__head mb-1">
|
|
<span class="ag-card__title"><i class="pi pi-calendar" />{{ visibleTitle }}</span>
|
|
<div class="flex items-center gap-0.5">
|
|
<Button icon="pi pi-home" severity="secondary" text class="h-7 w-7 rounded-full" v-tooltip.top="'Hoje'" @click="miniGoToday" />
|
|
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniPrevMonth" />
|
|
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />
|
|
</div>
|
|
</div>
|
|
|
|
<DatePicker v-model="miniDate" inline class="ag-mini-cal" @update:modelValue="onMiniPick" :pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }">
|
|
<template #date="{ date }">
|
|
<span class="mini-day-num">{{ date.day }}</span>
|
|
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
|
|
</template>
|
|
</DatePicker>
|
|
</div>
|
|
|
|
<ProximosFeriadosCard :ownerId="clinicOwnerId" :tenantId="tenantId || ''" :workRules="workRules" @bloqueado="refetch" />
|
|
|
|
<div class="ag-card">
|
|
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dialog Busca (mobile + desktop) -->
|
|
<Dialog v-model:visible="searchModalOpen" modal header="Buscar na agenda" :style="{ width: '96vw', maxWidth: '720px' }" :breakpoints="{ '960px': '92vw', '640px': '96vw' }" :draggable="false">
|
|
<div class="flex flex-col gap-3">
|
|
<!-- Campo + seletor de escopo -->
|
|
<div class="flex gap-2 items-end">
|
|
<FloatLabel variant="on" class="flex-1">
|
|
<IconField>
|
|
<InputIcon><i class="pi pi-search" /></InputIcon>
|
|
<InputText id="agendaSearchModal" v-model="search" class="w-full" autocomplete="off" autofocus />
|
|
</IconField>
|
|
<label for="agendaSearchModal">Paciente, título ou observação…</label>
|
|
</FloatLabel>
|
|
|
|
<!-- Escopo: Período / Mês -->
|
|
<div class="flex rounded-full border border-[var(--surface-border)] overflow-hidden shrink-0">
|
|
<button
|
|
class="px-3 py-2 text-xs font-semibold transition"
|
|
:class="searchScope === 'range' ? 'bg-[var(--p-primary-500)] text-white' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:bg-[var(--surface-hover)]'"
|
|
@click="searchScope = 'range'"
|
|
>
|
|
Período
|
|
</button>
|
|
<button
|
|
class="px-3 py-2 text-xs font-semibold transition"
|
|
:class="searchScope === 'month' ? 'bg-[var(--p-primary-500)] text-white' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:bg-[var(--surface-hover)]'"
|
|
@click="searchScope = 'month'"
|
|
>
|
|
Mês
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider class="my-0" />
|
|
|
|
<!-- Loading -->
|
|
<div v-if="monthSearchLoading" class="flex items-center gap-2 text-sm text-color-secondary py-2"><i class="pi pi-spin pi-spinner" /> Buscando no mês…</div>
|
|
|
|
<!-- Aguardando digitação -->
|
|
<div v-else-if="!searchTrim" class="text-color-secondary text-sm py-2">Digite para buscar compromissos na agenda.</div>
|
|
|
|
<!-- Sem resultados -->
|
|
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
|
|
Nenhum resultado para "<b>{{ searchTrim }}</b
|
|
>" <span class="text-xs"> ({{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }})</span>.
|
|
</div>
|
|
|
|
<!-- Resultados -->
|
|
<div v-else class="flex flex-col gap-2 max-h-[60vh] overflow-auto pr-1">
|
|
<div class="text-xs text-color-secondary mb-1">
|
|
{{ searchResults.length }} resultado(s) ·
|
|
<span>{{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }}</span>
|
|
</div>
|
|
|
|
<button
|
|
v-for="r in searchResults"
|
|
:key="r.id"
|
|
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 transition hover:shadow-md hover:border-[var(--p-primary-300)]"
|
|
@click="gotoResultFromModal(r)"
|
|
>
|
|
<!-- Linha 1: data + hora + tipo -->
|
|
<div class="flex items-center justify-between gap-2 mb-1">
|
|
<span class="text-xs font-bold text-[var(--p-primary-500)]">
|
|
{{ fmtDateOnly(r.inicio_em) }}
|
|
<span class="font-normal text-color-secondary ml-1">{{ fmtTimeOnly(r.inicio_em) }}</span>
|
|
</span>
|
|
<Tag :value="labelTipo(r.tipo)" severity="info" class="text-xs" />
|
|
</div>
|
|
|
|
<!-- Linha 2: nome do paciente ou título -->
|
|
<div class="font-semibold text-sm truncate">
|
|
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
|
|
</div>
|
|
<span v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'" class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5">{{
|
|
r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'
|
|
}}</span>
|
|
|
|
<!-- Linha 3: título (se paciente diferente de título) -->
|
|
<div v-if="(r.paciente_nome || r.patient_name) && r.titulo" class="text-xs text-color-secondary truncate">
|
|
{{ r.titulo }}
|
|
</div>
|
|
|
|
<!-- Linha 4: observações -->
|
|
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate opacity-75">
|
|
{{ r.observacoes }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
|
|
<Button v-if="searchTrim" label="Limpar busca" icon="pi pi-eraser" severity="secondary" outlined class="rounded-full" @click="clearSearchAndClose" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Month Picker -->
|
|
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
|
|
<div class="p-2">
|
|
<DatePicker v-model="monthPickerDate" view="month" dateFormat="mm/yy" class="w-full" :locale="ptCalendarLocale" />
|
|
<div class="mt-3 flex justify-end gap-2">
|
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="monthPickerVisible = false" />
|
|
<Button label="Ir" class="rounded-full" @click="applyMonthPick" />
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<!-- Dialog real -->
|
|
<AgendaEventDialog
|
|
v-model="dialogOpen"
|
|
:eventRow="dialogEventRow"
|
|
:initialStartISO="dialogStartISO"
|
|
:initialEndISO="dialogEndISO"
|
|
:ownerId="dialogOwnerId || clinicOwnerId"
|
|
:planOwnerId="clinicOwnerId"
|
|
:tenantId="tenantId || ''"
|
|
:allowOwnerEdit="false"
|
|
:ownerOptions="ownerOptions"
|
|
:commitmentOptions="filteredCommitmentOptions"
|
|
:presetCommitmentId="dialogPresetCommitmentId"
|
|
:lockCommitment="dialogLockCommitment"
|
|
:restrictPatientsToOwner="restrictPatientsToOwner"
|
|
:patientScopeOwnerId="patientScopeOwnerId"
|
|
:workRules="workRules"
|
|
:blockedDates="[...miniBlockedDaySet]"
|
|
:agendaSettings="settings"
|
|
:allEvents="baseRows"
|
|
:pausasSemanais="settings?.pausas_semanais || []"
|
|
:feriados="feriadosTodosProximos"
|
|
newPatientRoute="/admin/pacientes/cadastro"
|
|
@save="onDialogSave"
|
|
@delete="onDialogDelete"
|
|
@updateSeriesEvent="onUpdateSeriesEvent"
|
|
@editSeriesOccurrence="onEditSeriesOccurrence"
|
|
/>
|
|
|
|
<!-- Dialog de Bloqueio -->
|
|
<BloqueioDialog v-model="bloqueioDialogOpen" :mode="bloqueioMode" :workRules="workRules" :settings="settings" :ownerId="clinicOwnerId" :tenantId="tenantId || ''" @saved="refetch" />
|
|
|
|
<!-- Dialog: feriados próximos (todos os dias úteis — bloqueados e pendentes) -->
|
|
<Dialog v-model:visible="feriadosAlertaOpen" modal :draggable="false" header="Feriados nos próximos 30 dias" :style="{ width: '520px', maxWidth: '96vw' }">
|
|
<div class="flex flex-col gap-3">
|
|
<div v-if="feriadosSemBloqueio.length" class="flex items-start gap-3 p-3 rounded-2xl" style="background: color-mix(in srgb, var(--red-400) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--red-400) 28%, transparent)">
|
|
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" style="color: var(--red-500)" />
|
|
<p class="text-sm leading-relaxed m-0">{{ feriadosSemBloqueio.length }} feriado(s) em dias de trabalho sem bloqueio. Bloqueie para evitar agendamentos indevidos.</p>
|
|
</div>
|
|
<div v-else class="flex items-center gap-2 p-3 rounded-2xl" style="background: color-mix(in srgb, var(--green-400) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--green-400) 28%, transparent)">
|
|
<i class="pi pi-check-circle" style="color: var(--green-600)" />
|
|
<p class="text-sm m-0">Todos os feriados próximos estão bloqueados.</p>
|
|
</div>
|
|
|
|
<ul class="flex flex-col gap-2 max-h-[360px] overflow-y-auto pr-1">
|
|
<li v-for="f in feriadosTodosProximos" :key="f.data">
|
|
<div class="flex items-center gap-2 p-2 rounded-xl border" :class="f.bloqueado ? 'border-[var(--surface-border)] opacity-75' : 'border-red-300'">
|
|
<i class="text-sm shrink-0" :class="f.bloqueado ? 'pi pi-lock' : 'pi pi-calendar-times'" :style="{ color: f.bloqueado ? 'var(--text-color-secondary)' : 'var(--red-500)' }" />
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">{{ f.nome }}</div>
|
|
<div class="text-xs text-[var(--text-color-secondary)] capitalize">{{ fmtFeriadoDateLong(f.data) }}</div>
|
|
</div>
|
|
|
|
<!-- Já bloqueado -->
|
|
<template v-if="f.bloqueado">
|
|
<span class="text-xs text-[var(--text-color-secondary)] flex items-center gap-1 shrink-0 mr-1"> <i class="pi pi-check-circle" style="color: var(--green-500)" /> Bloqueado </span>
|
|
<Button label="Desfazer" icon="pi pi-lock-open" size="small" severity="secondary" outlined class="rounded-full shrink-0" :loading="feriadosAlertaSalvando === 'unblock_' + f.data" @click="desbloquearFeriadoDoAlerta(f)" />
|
|
</template>
|
|
|
|
<!-- Não bloqueado -->
|
|
<Button v-else label="Bloquear" icon="pi pi-lock" size="small" severity="danger" outlined class="rounded-full shrink-0" :loading="feriadosAlertaSalvando === f.data" @click="bloquearFeriadoDoAlerta(f)" />
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="feriadosAlertaOpen = false" />
|
|
<Button v-if="feriadosSemBloqueio.length" label="Bloquear todos" icon="pi pi-lock" severity="danger" class="rounded-full" :loading="feriadosAlertaSalvando === 'all'" @click="bloquearTodosFeriadosAlerta" />
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.evt-private) {
|
|
opacity: 0.9;
|
|
filter: saturate(0.25);
|
|
}
|
|
:deep(.evt-private.fc-event) {
|
|
border-style: dashed;
|
|
}
|
|
|
|
/* ── Topbar ─────────────────────────────────────────── */
|
|
.ag-sentinel {
|
|
height: 1px;
|
|
}
|
|
|
|
.ag-topbar {
|
|
position: sticky;
|
|
top: var(--layout-sticky-top, 56px);
|
|
z-index: 20;
|
|
overflow: hidden;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--surface-border);
|
|
background: var(--surface-card);
|
|
padding: 8px 12px;
|
|
}
|
|
.ag-topbar--stuck {
|
|
border-top-left-radius: 0;
|
|
border-top-right-radius: 0;
|
|
}
|
|
.ag-topbar__blobs {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
overflow: hidden;
|
|
}
|
|
.ag-topbar__blob {
|
|
position: absolute;
|
|
border-radius: 50%;
|
|
filter: blur(60px);
|
|
}
|
|
.ag-topbar__blob--1 {
|
|
width: 16rem;
|
|
height: 16rem;
|
|
top: -4rem;
|
|
right: -2rem;
|
|
background: rgba(99, 102, 241, 0.1);
|
|
}
|
|
.ag-topbar__blob--2 {
|
|
width: 18rem;
|
|
height: 18rem;
|
|
top: 0;
|
|
left: -4rem;
|
|
background: rgba(52, 211, 153, 0.07);
|
|
}
|
|
|
|
.ag-topbar__inner {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.ag-topbar__brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.ag-topbar__icon {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border-radius: 6px;
|
|
flex-shrink: 0;
|
|
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
|
color: var(--p-primary-500, #6366f1);
|
|
}
|
|
.ag-topbar__title {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
color: var(--text-color);
|
|
}
|
|
.ag-topbar__sub {
|
|
font-size: 0.75rem;
|
|
color: var(--text-color-secondary);
|
|
}
|
|
.ag-topbar__nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
.ag-topbar__date-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--surface-border);
|
|
background: var(--surface-ground);
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.ag-topbar__date-pill:hover {
|
|
border-color: var(--p-primary-400);
|
|
}
|
|
.ag-topbar__filters {
|
|
flex-shrink: 0;
|
|
}
|
|
.ag-topbar__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-left: auto;
|
|
}
|
|
|
|
/* ── Badge ────────────────────────────────────────────── */
|
|
.ag-badge {
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
border-radius: 999px;
|
|
padding: 0 4px;
|
|
background: var(--red-500, #ef4444);
|
|
color: #fff;
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ── Calendar wrap ──────────────────────────────────── */
|
|
.ag-cal-wrap {
|
|
border: 1px solid var(--surface-border);
|
|
border-radius: 6px;
|
|
background: var(--surface-card);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Sidebar cards ──────────────────────────────────── */
|
|
.ag-card {
|
|
border: 1px solid var(--surface-border);
|
|
border-radius: 6px;
|
|
background: var(--surface-card);
|
|
padding: 0.75rem;
|
|
}
|
|
.ag-card__head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.ag-card__title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text-color-secondary);
|
|
opacity: 0.65;
|
|
}
|
|
|
|
/* ── Mini calendário ────────────────────────────────── */
|
|
:deep(.ag-mini-cal .p-datepicker) {
|
|
width: 100%;
|
|
border: none;
|
|
padding: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
:deep(.ag-mini-cal .p-datepicker-header) {
|
|
padding: 0 0 0.5rem;
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
:deep(.ag-mini-cal .p-datepicker-calendar) {
|
|
width: 100%;
|
|
font-size: 0.78rem;
|
|
}
|
|
:deep(.ag-mini-cal .p-datepicker-calendar td) {
|
|
padding: 1px;
|
|
}
|
|
:deep(.ag-mini-cal .p-datepicker-calendar td > span) {
|
|
width: 100%;
|
|
min-width: unset;
|
|
border-radius: 6px;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
aspect-ratio: 1;
|
|
}
|
|
:deep(.p-disabled.mini-day-work) {
|
|
background: color-mix(in srgb, #9ca3af 18%, transparent) !important;
|
|
opacity: 0.6;
|
|
}
|
|
.mini-day-num {
|
|
display: block;
|
|
text-align: center;
|
|
line-height: 1;
|
|
}
|
|
.mini-day-dot {
|
|
position: absolute;
|
|
bottom: 2px;
|
|
right: 2px;
|
|
width: 4px;
|
|
height: 4px;
|
|
border-radius: 50%;
|
|
background: var(--primary-color, #6366f1);
|
|
}
|
|
|
|
/* Semana atual — faixa de fundo contínua seg→dom */
|
|
:deep(.mini-week-hl) {
|
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent) !important;
|
|
border-radius: 0 !important;
|
|
}
|
|
:deep(.mini-week-hl--start) {
|
|
border-radius: 6px 0 0 6px !important;
|
|
}
|
|
:deep(.mini-week-hl--end) {
|
|
border-radius: 0 6px 6px 0 !important;
|
|
}
|
|
|
|
/* Hoje — cartão com borda + sombra */
|
|
:deep(.mini-day-today) {
|
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 80%, #00000000) !important;
|
|
border: 1px solid var(--surface-border) !important;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
|
|
border-radius: 6px !important;
|
|
color: #ffffff !important;
|
|
font-weight: 600 !important;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* ── Altura mínima dos slots ───────────────────────────── */
|
|
.fc-timegrid-slot {
|
|
height: 14px !important;
|
|
}
|
|
.fc-timegrid-slot-label {
|
|
font-size: 10px !important;
|
|
line-height: 1 !important;
|
|
}
|
|
|
|
/* Mini calendário — colorir dias por expediente */
|
|
.p-datepicker-day.mini-day-work:not(.p-datepicker-day-selected) {
|
|
background: rgba(34, 197, 94, 0.25);
|
|
}
|
|
.p-datepicker-day.mini-day-off:not(.p-datepicker-day-selected) {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
}
|
|
.p-datepicker-day.mini-day-blocked:not(.p-datepicker-day-selected) {
|
|
background: rgba(239, 68, 68, 0.55);
|
|
color: #fff;
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* Slot #date — número + bolinha */
|
|
.p-datepicker-day span {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.mini-day-num {
|
|
display: block;
|
|
line-height: 1;
|
|
}
|
|
.mini-day-dot {
|
|
right: -2px;
|
|
width: 5px;
|
|
height: 5px;
|
|
border-radius: 50%;
|
|
background: var(--primary-color, #6366f1);
|
|
}
|
|
|
|
/* Badge numérico no header */
|
|
.ag-badge {
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
border-radius: 999px;
|
|
background: var(--red-500, #ef4444);
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 3px;
|
|
pointer-events: none;
|
|
line-height: 1;
|
|
}
|
|
</style>
|