Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
File diff suppressed because it is too large Load Diff
+200 -165
View File
@@ -15,245 +15,280 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue';
const isOnline = ref(true) // começa como true; detecta em onMounted
const wasOffline = ref(false)
const showReconnected = ref(false)
const isOnline = ref(true); // começa como true; detecta em onMounted
const wasOffline = ref(false);
const showReconnected = ref(false);
let pollTimer = null
let reconnectedTimer = null
let pollTimer = null;
let reconnectedTimer = null;
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
async function checkConnectivity () {
try {
// favicon do próprio app (cache busted) — não depende de rede externa
await fetch('/favicon.ico?_t=' + Date.now(), {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000)
})
setOnline()
} catch {
setOffline()
}
async function checkConnectivity() {
try {
// favicon do próprio app (cache busted) — não depende de rede externa
await fetch('/favicon.ico?_t=' + Date.now(), {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000)
});
setOnline();
} catch {
setOffline();
}
}
function setOnline () {
if (!isOnline.value && wasOffline.value) {
// acabou de reconectar
showReconnected.value = true
if (reconnectedTimer) clearTimeout(reconnectedTimer)
reconnectedTimer = setTimeout(() => { showReconnected.value = false }, 4000)
}
isOnline.value = true
function setOnline() {
if (!isOnline.value && wasOffline.value) {
// acabou de reconectar
showReconnected.value = true;
if (reconnectedTimer) clearTimeout(reconnectedTimer);
reconnectedTimer = setTimeout(() => {
showReconnected.value = false;
}, 4000);
}
isOnline.value = true;
}
function setOffline () {
if (isOnline.value) wasOffline.value = true
showReconnected.value = false
if (reconnectedTimer) clearTimeout(reconnectedTimer)
isOnline.value = false
function setOffline() {
if (isOnline.value) wasOffline.value = true;
showReconnected.value = false;
if (reconnectedTimer) clearTimeout(reconnectedTimer);
isOnline.value = false;
}
// ── Eventos nativos do browser ────────────────────────────────
function onBrowserOffline () { setOffline() }
function onBrowserOnline () { checkConnectivity() } // confirma antes de marcar online
function onBrowserOffline() {
setOffline();
}
function onBrowserOnline() {
checkConnectivity();
} // confirma antes de marcar online
onMounted(() => {
window.addEventListener('offline', onBrowserOffline)
window.addEventListener('online', onBrowserOnline)
window.addEventListener('offline', onBrowserOffline);
window.addEventListener('online', onBrowserOnline);
// Polling a cada 10 s — captura quedas que não disparam evento
pollTimer = setInterval(checkConnectivity, 10_000)
// Polling a cada 10 s — captura quedas que não disparam evento
pollTimer = setInterval(checkConnectivity, 10_000);
// Verifica estado atual ao montar (útil se já começou offline)
checkConnectivity()
})
// Verifica estado atual ao montar (útil se já começou offline)
checkConnectivity();
});
onBeforeUnmount(() => {
window.removeEventListener('offline', onBrowserOffline)
window.removeEventListener('online', onBrowserOnline)
clearInterval(pollTimer)
clearTimeout(reconnectedTimer)
})
window.removeEventListener('offline', onBrowserOffline);
window.removeEventListener('online', onBrowserOnline);
clearInterval(pollTimer);
clearTimeout(reconnectedTimer);
});
</script>
<template>
<Teleport to="body">
<!-- Overlay: sem internet -->
<Transition name="offline-fade">
<div
v-if="!isOnline"
class="offline-overlay"
role="alertdialog"
aria-live="assertive"
aria-label="Sem conexão com a internet"
>
<div class="offline-backdrop" />
<Teleport to="body">
<!-- Overlay: sem internet -->
<Transition name="offline-fade">
<div v-if="!isOnline" class="offline-overlay" role="alertdialog" aria-live="assertive" aria-label="Sem conexão com a internet">
<div class="offline-backdrop" />
<div class="offline-card">
<div class="offline-icon-wrap">
<span class="offline-icon-ring" />
<i class="pi pi-wifi offline-icon" />
</div>
<div class="offline-card">
<div class="offline-icon-wrap">
<span class="offline-icon-ring" />
<i class="pi pi-wifi offline-icon" />
</div>
<h2 class="offline-title">Sem conexão</h2>
<p class="offline-desc">
Verifique sua internet.<br>Tentando reconectar automaticamente
</p>
<h2 class="offline-title">Sem conexão</h2>
<p class="offline-desc">Verifique sua internet.<br />Tentando reconectar automaticamente</p>
<div class="offline-pulse-bar">
<span class="offline-pulse-dot" />
<span class="offline-pulse-dot" style="animation-delay:.2s" />
<span class="offline-pulse-dot" style="animation-delay:.4s" />
</div>
</div>
</div>
</Transition>
<div class="offline-pulse-bar">
<span class="offline-pulse-dot" />
<span class="offline-pulse-dot" style="animation-delay: 0.2s" />
<span class="offline-pulse-dot" style="animation-delay: 0.4s" />
</div>
</div>
</div>
</Transition>
<!-- Toast: reconectou -->
<Transition name="reconnect-toast">
<div v-if="showReconnected" class="reconnect-toast" role="status">
<i class="pi pi-check-circle" />
<span>Conexão restabelecida</span>
</div>
</Transition>
</Teleport>
<!-- Toast: reconectou -->
<Transition name="reconnect-toast">
<div v-if="showReconnected" class="reconnect-toast" role="status">
<i class="pi pi-check-circle" />
<span>Conexão restabelecida</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────────────────── */
.offline-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.offline-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* ── Card ────────────────────────────────────────────────── */
.offline-card {
position: relative;
z-index: 1;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e0e0e0);
border-radius: 20px;
padding: 40px 48px;
max-width: 380px;
width: 100%;
text-align: center;
box-shadow: 0 24px 60px rgba(0, 0, 0, .25);
position: relative;
z-index: 1;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e0e0e0);
border-radius: 20px;
padding: 40px 48px;
max-width: 380px;
width: 100%;
text-align: center;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
}
/* ── Ícone ───────────────────────────────────────────────── */
.offline-icon-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 24px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 24px;
}
.offline-icon-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--primary-color, #6366f1);
opacity: 0.25;
animation: ring-pulse 2s ease-in-out infinite;
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--primary-color, #6366f1);
opacity: 0.25;
animation: ring-pulse 2s ease-in-out infinite;
}
.offline-icon {
font-size: 2.2rem;
color: var(--primary-color, #6366f1);
opacity: 0.85;
position: relative;
font-size: 2.2rem;
color: var(--primary-color, #6366f1);
opacity: 0.85;
position: relative;
}
@keyframes ring-pulse {
0%, 100% { transform: scale(1); opacity: 0.25; }
50% { transform: scale(1.18); opacity: 0.10; }
0%,
100% {
transform: scale(1);
opacity: 0.25;
}
50% {
transform: scale(1.18);
opacity: 0.1;
}
}
/* ── Texto ───────────────────────────────────────────────── */
.offline-title {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-color, #1a1a2e);
margin: 0 0 10px;
font-size: 1.3rem;
font-weight: 700;
color: var(--text-color, #1a1a2e);
margin: 0 0 10px;
}
.offline-desc {
font-size: 0.88rem;
color: var(--text-color-secondary, #666);
margin: 0 0 28px;
line-height: 1.6;
font-size: 0.88rem;
color: var(--text-color-secondary, #666);
margin: 0 0 28px;
line-height: 1.6;
}
/* ── Dots de pulso ───────────────────────────────────────── */
.offline-pulse-bar {
display: flex;
justify-content: center;
gap: 7px;
display: flex;
justify-content: center;
gap: 7px;
}
.offline-pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color, #6366f1);
opacity: 0.7;
animation: dot-bounce 1.2s ease-in-out infinite;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color, #6366f1);
opacity: 0.7;
animation: dot-bounce 1.2s ease-in-out infinite;
}
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.7); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* ── Transição do overlay ────────────────────────────────── */
.offline-fade-enter-active { transition: opacity 0.3s ease; }
.offline-fade-leave-active { transition: opacity 0.4s ease; }
.offline-fade-enter-active {
transition: opacity 0.3s ease;
}
.offline-fade-leave-active {
transition: opacity 0.4s ease;
}
.offline-fade-enter-from,
.offline-fade-leave-to { opacity: 0; }
.offline-fade-leave-to {
opacity: 0;
}
/* ── Toast de reconexão ──────────────────────────────────── */
.reconnect-toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
gap: 8px;
background: #16a34a;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
padding: 10px 20px;
border-radius: 999px;
box-shadow: 0 4px 20px rgba(22, 163, 74, .4);
white-space: nowrap;
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
gap: 8px;
background: #16a34a;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
padding: 10px 20px;
border-radius: 999px;
box-shadow: 0 4px 20px rgba(22, 163, 74, 0.4);
white-space: nowrap;
}
.reconnect-toast .pi { font-size: 1rem; }
.reconnect-toast .pi {
font-size: 1rem;
}
.reconnect-toast-enter-active { transition: opacity 0.3s ease, transform 0.3s ease; }
.reconnect-toast-leave-active { transition: opacity 0.4s ease, transform 0.4s ease; }
.reconnect-toast-enter-from { opacity: 0; transform: translateX(-50%) translateY(12px); }
.reconnect-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(12px); }
.reconnect-toast-enter-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.reconnect-toast-leave-active {
transition:
opacity 0.4s ease,
transform 0.4s ease;
}
.reconnect-toast-enter-from {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
.reconnect-toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
</style>
+271 -353
View File
@@ -14,433 +14,351 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Dialog
v-model:visible="isOpen"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '34rem', maxWidth: '92vw' }"
pt:mask:class="backdrop-blur-xs"
@hide="onHide"
>
<template #header>
<div class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-xl font-semibold">{{ title }}</div>
<div class="text-sm text-surface-500">
Crie um paciente rapidamente.
</div>
</div>
<!-- TOPBAR ACTION -->
<Button
v-if="canSee('testMODE')"
label="Gerar usuário"
icon="pi pi-user-plus"
severity="secondary"
outlined
:disabled="saving"
@click="generateUser"
/>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<Message v-if="errorMsg" severity="error" :closable="false">
{{ errorMsg }}
</Message>
<div class="flex flex-col gap-2 mt-2">
<!-- Nome -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText
id="cr-nome"
v-model.trim="form.nome_completo"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent="submit('only')"
/>
</IconField>
<label for="cr-nome">Nome completo *</label>
</FloatLabel>
</div>
<div class="flex flex-col gap-2">
<!-- E-mail -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText
id="cr-email"
v-model.trim="form.email_principal"
class="w-full"
variant="filled"
:disabled="saving"
inputmode="email"
autocomplete="off"
@keydown.enter.prevent="submit('only')"
/>
</IconField>
<label for="cr-email">E-mail *</label>
</FloatLabel>
</div>
<div class="flex flex-col gap-2">
<!-- Telefone -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputMask
id="cr-telefone"
v-model="form.telefone"
mask="(99) 99999-9999"
class="w-full"
variant="filled"
:disabled="saving"
@keydown.enter.prevent="submit('only')"
/>
</IconField>
<label for="cr-telefone">Telefone *</label>
</FloatLabel>
</div>
<div class="text-xs text-surface-500">
Dica: "Gerar usuário" preenche automaticamente com dados fictícios.
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button
label="Cancelar"
severity="secondary"
text
:disabled="saving"
@click="close"
/>
<!-- Na rota de pacientes: "Salvar" -->
<Button
v-if="isOnPatientsPage"
label="Salvar"
:loading="saving"
:disabled="saving"
@click="submit('only')"
/>
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
<template v-else>
<Button
label="Salvar e fechar"
severity="secondary"
outlined
:loading="saving"
:disabled="saving"
@click="submit('only')"
/>
<Button
label="Salvar e ver pacientes"
:loading="saving"
:disabled="saving"
@click="submit('view')"
/>
</template>
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoleGuard } from '@/composables/useRoleGuard';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
import InputMask from 'primevue/inputmask'
import Message from 'primevue/message'
import InputMask from 'primevue/inputmask';
import Message from 'primevue/message';
import { supabase } from '@/lib/supabase/client'
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
import { supabase } from '@/lib/supabase/client';
const { canSee } = useRoleGuard();
const route = useRoute();
const router = useRouter();
const isOnPatientsPage = computed(() => {
const p = String(route.path || '')
return p.includes('/patients') || p.includes('/pacientes')
})
const p = String(route.path || '');
return p.includes('/patients') || p.includes('/pacientes');
});
/**
* Lista "curada" de pensadores influentes na psicanálise e seu entorno.
* Usada para geração rápida de dados fictícios.
*/
const PSICANALISE_PENSADORES = Object.freeze([
{ nome: 'Sigmund Freud' },
{ nome: 'Jacques Lacan' },
{ nome: 'Melanie Klein' },
{ nome: 'Donald Winnicott' },
{ nome: 'Wilfred Bion' },
{ nome: 'Sándor Ferenczi' },
{ nome: 'Anna Freud' },
{ nome: 'Karl Abraham' },
{ nome: 'Otto Rank' },
{ nome: 'Karen Horney' },
{ nome: 'Erich Fromm' },
{ nome: 'Michael Balint' },
{ nome: 'Ronald Fairbairn' },
{ nome: 'John Bowlby' },
{ nome: 'André Green' },
{ nome: 'Jean Laplanche' },
{ nome: 'Christopher Bollas' },
{ nome: 'Thomas Ogden' },
{ nome: 'Jessica Benjamin' },
{ nome: 'Joyce McDougall' },
{ nome: 'Peter Fonagy' },
{ nome: 'Carl Gustav Jung' },
{ nome: 'Alfred Adler' }
])
{ nome: 'Sigmund Freud' },
{ nome: 'Jacques Lacan' },
{ nome: 'Melanie Klein' },
{ nome: 'Donald Winnicott' },
{ nome: 'Wilfred Bion' },
{ nome: 'Sándor Ferenczi' },
{ nome: 'Anna Freud' },
{ nome: 'Karl Abraham' },
{ nome: 'Otto Rank' },
{ nome: 'Karen Horney' },
{ nome: 'Erich Fromm' },
{ nome: 'Michael Balint' },
{ nome: 'Ronald Fairbairn' },
{ nome: 'John Bowlby' },
{ nome: 'André Green' },
{ nome: 'Jean Laplanche' },
{ nome: 'Christopher Bollas' },
{ nome: 'Thomas Ogden' },
{ nome: 'Jessica Benjamin' },
{ nome: 'Joyce McDougall' },
{ nome: 'Peter Fonagy' },
{ nome: 'Carl Gustav Jung' },
{ nome: 'Alfred Adler' }
]);
// domínio seguro para dados fictícios
const AUTO_EMAIL_DOMAIN = 'example.com'
const AUTO_EMAIL_DOMAIN = 'example.com';
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: 'Cadastro rápido' },
modelValue: { type: Boolean, default: false },
title: { type: String, default: 'Cadastro rápido' },
tableName: { type: String, default: 'patients' },
ownerField: { type: String, default: 'owner_id' },
tableName: { type: String, default: 'patients' },
ownerField: { type: String, default: 'owner_id' },
// defaults alinhados com seu schema
nameField: { type: String, default: 'nome_completo' },
emailField: { type: String, default: 'email_principal' },
phoneField: { type: String, default: 'telefone' },
// defaults alinhados com seu schema
nameField: { type: String, default: 'nome_completo' },
emailField: { type: String, default: 'email_principal' },
phoneField: { type: String, default: 'telefone' },
// multi-tenant (defaults do seu schema)
tenantField: { type: String, default: 'tenant_id' },
responsibleMemberField: { type: String, default: 'responsible_member_id' },
// multi-tenant (defaults do seu schema)
tenantField: { type: String, default: 'tenant_id' },
responsibleMemberField: { type: String, default: 'responsible_member_id' },
extraPayload: { type: Object, default: () => ({}) },
extraPayload: { type: Object, default: () => ({}) },
closeOnCreated: { type: Boolean, default: true },
resetOnOpen: { type: Boolean, default: true }
})
closeOnCreated: { type: Boolean, default: true },
resetOnOpen: { type: Boolean, default: true }
});
const emit = defineEmits(['update:modelValue', 'created'])
const toast = useToast()
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const saving = ref(false)
const touched = ref(false)
const errorMsg = ref('')
const saving = ref(false);
const touched = ref(false);
const errorMsg = ref('');
const form = reactive({
nome_completo: '',
email_principal: '',
telefone: ''
})
nome_completo: '',
email_principal: '',
telefone: ''
});
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
});
watch(
() => props.modelValue,
(v) => {
if (v && props.resetOnOpen) reset()
if (v) {
touched.value = false
errorMsg.value = ''
() => props.modelValue,
(v) => {
if (v && props.resetOnOpen) reset();
if (v) {
touched.value = false;
errorMsg.value = '';
}
}
}
)
);
function reset () {
form.nome_completo = ''
form.email_principal = ''
form.telefone = ''
function reset() {
form.nome_completo = '';
form.email_principal = '';
form.telefone = '';
}
function close () {
isOpen.value = false
function close() {
isOpen.value = false;
}
function onHide () {}
function onHide() {}
function isValidEmail (v) {
return /.+@.+\..+/.test(String(v || '').trim())
function isValidEmail(v) {
return /.+@.+\..+/.test(String(v || '').trim());
}
function isValidPhone (v) {
const digits = String(v || '').replace(/\D/g, '')
return digits.length === 10 || digits.length === 11
function isValidPhone(v) {
const digits = String(v || '').replace(/\D/g, '');
return digits.length === 10 || digits.length === 11;
}
function normalizePhoneDigits (v) {
const digits = String(v || '').replace(/\D/g, '')
return digits || null
function normalizePhoneDigits(v) {
const digits = String(v || '').replace(/\D/g, '');
return digits || null;
}
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const user = data?.user
if (!user?.id) throw new Error('Usuário não encontrado. Faça login novamente.')
return user.id
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const user = data?.user;
if (!user?.id) throw new Error('Usuário não encontrado. Faça login novamente.');
return user.id;
}
/**
* Pega tenant_id + member_id do usuário logado.
*/
async function resolveTenantContextOrFail () {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
const uid = authData?.user?.id
if (!uid) throw new Error('Sessão inválida.')
async function resolveTenantContextOrFail() {
const { data: authData, error: authError } = await supabase.auth.getUser();
if (authError) throw authError;
const uid = authData?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
const { data, error } = await supabase
.from('tenant_members')
.select('id, tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single()
const { data, error } = await supabase.from('tenant_members').select('id, tenant_id').eq('user_id', uid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).single();
if (error) throw error
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
if (error) throw error;
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found');
return { tenantId: data.tenant_id, memberId: data.id }
return { tenantId: data.tenant_id, memberId: data.id };
}
/* ----------------------------
* Gerador (nome/email/telefone)
* ---------------------------- */
function randInt (min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function pick (arr) {
return arr[randInt(0, arr.length - 1)]
function pick(arr) {
return arr[randInt(0, arr.length - 1)];
}
function slugify (s) {
return String(s || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '.')
.replace(/(^\.)|(\.$)/g, '')
function slugify(s) {
return String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '.')
.replace(/(^\.)|(\.$)/g, '');
}
function randomPhoneBRMasked () {
const ddd = randInt(11, 99)
const a = randInt(10000, 99999)
const b = randInt(1000, 9999)
return `(${ddd}) ${a}-${b}`
function randomPhoneBRMasked() {
const ddd = randInt(11, 99);
const a = randInt(10000, 99999);
const b = randInt(1000, 9999);
return `(${ddd}) ${a}-${b}`;
}
function generateUser () {
if (saving.value) return
function generateUser() {
if (saving.value) return;
const p = pick(PSICANALISE_PENSADORES)
const nome = p?.nome || 'Paciente'
const p = pick(PSICANALISE_PENSADORES);
const nome = p?.nome || 'Paciente';
const base = slugify(nome) || 'paciente'
const suffix = randInt(10, 999)
const email = `${base}.${suffix}@${AUTO_EMAIL_DOMAIN}`
const base = slugify(nome) || 'paciente';
const suffix = randInt(10, 999);
const email = `${base}.${suffix}@${AUTO_EMAIL_DOMAIN}`;
form.nome_completo = nome
form.email_principal = email
form.telefone = randomPhoneBRMasked()
form.nome_completo = nome;
form.email_principal = email;
form.telefone = randomPhoneBRMasked();
touched.value = true
errorMsg.value = ''
touched.value = true;
errorMsg.value = '';
toast.add({
severity: 'info',
summary: 'Gerar usuário',
detail: 'Dados fictícios preenchidos.',
life: 2200
})
toast.add({
severity: 'info',
summary: 'Gerar usuário',
detail: 'Dados fictícios preenchidos.',
life: 2200
});
}
function patientsListRoute () {
const p = String(route.path || '')
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes'
function patientsListRoute() {
const p = String(route.path || '');
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
async function submit (mode = 'only') {
touched.value = true
errorMsg.value = ''
async function submit(mode = 'only') {
touched.value = true;
errorMsg.value = '';
const nome = String(form.nome_completo || '').trim()
const email = String(form.email_principal || '').trim()
const tel = String(form.telefone || '')
const nome = String(form.nome_completo || '').trim();
const email = String(form.email_principal || '').trim();
const tel = String(form.telefone || '');
if (!nome) return
if (!email) return
if (!isValidEmail(email)) return
if (!tel) return
if (!isValidPhone(tel)) return
if (!nome) return;
if (!email) return;
if (!isValidEmail(email)) return;
if (!tel) return;
if (!isValidPhone(tel)) return;
saving.value = true
try {
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
saving.value = true;
try {
const ownerId = await getOwnerId();
const { tenantId, memberId } = await resolveTenantContextOrFail();
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
const payload = {
...props.extraPayload,
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
const payload = {
...props.extraPayload,
[props.ownerField]: ownerId,
[props.tenantField]: tenantId,
[props.responsibleMemberField]: memberId,
[props.ownerField]: ownerId,
[props.tenantField]: tenantId,
[props.responsibleMemberField]: memberId,
[props.nameField]: nome,
[props.emailField]: email.toLowerCase(),
[props.phoneField]: normalizePhoneDigits(tel)
[props.nameField]: nome,
[props.emailField]: email.toLowerCase(),
[props.phoneField]: normalizePhoneDigits(tel)
};
Object.keys(payload).forEach((k) => {
if (payload[k] === undefined) delete payload[k];
});
const { data, error } = await supabase.from(props.tableName).insert(payload).select().single();
if (error) throw error;
toast.add({
severity: 'success',
summary: 'Paciente criado',
detail: nome,
life: 2500
});
emit('created', data);
if (props.closeOnCreated) close();
if (mode === 'view') await router.push(patientsListRoute());
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
errorMsg.value = msg;
toast.add({
severity: 'error',
summary: 'Erro ao salvar',
detail: msg,
life: 4500
});
console.error('[ComponentCadastroRapido] insert error:', err);
} finally {
saving.value = false;
}
Object.keys(payload).forEach((k) => {
if (payload[k] === undefined) delete payload[k]
})
const { data, error } = await supabase
.from(props.tableName)
.insert(payload)
.select()
.single()
if (error) throw error
toast.add({
severity: 'success',
summary: 'Paciente criado',
detail: nome,
life: 2500
})
emit('created', data)
if (props.closeOnCreated) close()
if (mode === 'view') await router.push(patientsListRoute())
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.'
errorMsg.value = msg
toast.add({
severity: 'error',
summary: 'Erro ao salvar',
detail: msg,
life: 4500
})
console.error('[ComponentCadastroRapido] insert error:', err)
} finally {
saving.value = false
}
}
</script>
</script>
<template>
<Dialog v-model:visible="isOpen" modal :draggable="false" :closable="!saving" :dismissableMask="!saving" :style="{ width: '34rem', maxWidth: '92vw' }" pt:mask:class="backdrop-blur-xs" @hide="onHide">
<template #header>
<div class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-xl font-semibold">{{ title }}</div>
<div class="text-sm text-surface-500">Crie um paciente rapidamente.</div>
</div>
<!-- TOPBAR ACTION -->
<Button v-if="canSee('testMODE')" label="Gerar usuário" icon="pi pi-user-plus" severity="secondary" outlined :disabled="saving" @click="generateUser" />
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<Message v-if="errorMsg" severity="error" :closable="false">
{{ errorMsg }}
</Message>
<div class="flex flex-col gap-2 mt-2">
<!-- Nome -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText id="cr-nome" v-model.trim="form.nome_completo" class="w-full" variant="filled" :disabled="saving" autocomplete="off" autofocus @keydown.enter.prevent="submit('only')" />
</IconField>
<label for="cr-nome">Nome completo *</label>
</FloatLabel>
</div>
<div class="flex flex-col gap-2">
<!-- E-mail -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText id="cr-email" v-model.trim="form.email_principal" class="w-full" variant="filled" :disabled="saving" inputmode="email" autocomplete="off" @keydown.enter.prevent="submit('only')" />
</IconField>
<label for="cr-email">E-mail *</label>
</FloatLabel>
</div>
<div class="flex flex-col gap-2">
<!-- Telefone -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputMask id="cr-telefone" v-model="form.telefone" mask="(99) 99999-9999" class="w-full" variant="filled" :disabled="saving" @keydown.enter.prevent="submit('only')" />
</IconField>
<label for="cr-telefone">Telefone *</label>
</FloatLabel>
</div>
<div class="text-xs text-surface-500">Dica: "Gerar usuário" preenche automaticamente com dados fictícios.</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button label="Cancelar" severity="secondary" text :disabled="saving" @click="close" />
<!-- Na rota de pacientes: "Salvar" -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
</template>
</div>
</template>
</Dialog>
</template>
@@ -30,387 +30,340 @@
cobranca-atualizada após qualquer mutação, para o pai recarregar
-->
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { ref, computed, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client'
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro'
import { supabase } from '@/lib/supabase/client';
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
// ── props / emits ─────────────────────────────────────────────────────────────
const props = defineProps({
evento: {
type: Object,
required: true,
},
})
evento: {
type: Object,
required: true
}
});
const emit = defineEmits(['cobranca-atualizada'])
const emit = defineEmits(['cobranca-atualizada']);
// ── external ──────────────────────────────────────────────────────────────────
const toast = useToast()
const confirm = useConfirm()
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro()
const toast = useToast();
const confirm = useConfirm();
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro();
// ── estado local ──────────────────────────────────────────────────────────────
const record = ref(null) // financial_record vinculado
const fetching = ref(false)
const generating = ref(false)
const record = ref(null); // financial_record vinculado
const fetching = ref(false);
const generating = ref(false);
// ── opções de método de pagamento ─────────────────────────────────────────────
const PAYMENT_METHODS = [
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Convênio', value: 'convenio' },
]
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Convênio', value: 'convenio' }
];
function paymentLabel (method) {
return PAYMENT_METHODS.find(o => o.value === method)?.label ?? method ?? '—'
function paymentLabel(method) {
return PAYMENT_METHODS.find((o) => o.value === method)?.label ?? method ?? '—';
}
// ── formatação ─────────────────────────────────────────────────────────────────
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL (v) { return _brl.format(v ?? 0) }
function fmtDate (iso) {
if (!iso) return '—'
const d = iso.includes('T') ? new Date(iso) : new Date(iso + 'T00:00:00')
return new Intl.DateTimeFormat('pt-BR').format(d)
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
function fmtBRL(v) {
return _brl.format(v ?? 0);
}
function fmtDate(iso) {
if (!iso) return '—';
const d = iso.includes('T') ? new Date(iso) : new Date(iso + 'T00:00:00');
return new Intl.DateTimeFormat('pt-BR').format(d);
}
// ── config visual de status ────────────────────────────────────────────────────
const STATUS_CFG = {
pending: { label: 'Pendente', severity: 'warn' },
paid: { label: 'Pago', severity: 'success' },
overdue: { label: 'Vencido', severity: 'danger' },
cancelled: { label: 'Cancelado', severity: 'secondary' },
}
pending: { label: 'Pendente', severity: 'warn' },
paid: { label: 'Pago', severity: 'success' },
overdue: { label: 'Vencido', severity: 'danger' },
cancelled: { label: 'Cancelado', severity: 'secondary' }
};
// ── computed: cenário a renderizar ────────────────────────────────────────────
const scenario = computed(() => {
if (props.evento.tipo !== 'sessao') return 'noop' // bloqueio
if (props.evento.billing_contract_id) return 'contrato' // pacote
if (fetching.value) return 'carregando'
if (record.value) return 'com-cobranca'
return 'sem-cobranca'
})
if (props.evento.tipo !== 'sessao') return 'noop'; // bloqueio
if (props.evento.billing_contract_id) return 'contrato'; // pacote
if (fetching.value) return 'carregando';
if (record.value) return 'com-cobranca';
return 'sem-cobranca';
});
const canAct = computed(() =>
record.value && (record.value.status === 'pending' || record.value.status === 'overdue')
)
const canAct = computed(() => record.value && (record.value.status === 'pending' || record.value.status === 'overdue'));
// ── buscar financial_record pelo evento ───────────────────────────────────────
async function fetchRecord () {
if (!props.evento.id) return
async function fetchRecord() {
if (!props.evento.id) return;
fetching.value = true
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
fetching.value = true;
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error
record.value = data ?? null
} catch (e) {
console.warn('[AgendaEventoFinanceiroPanel] fetchRecord:', e?.message)
record.value = null
} finally {
fetching.value = false
}
if (error) throw error;
record.value = data ?? null;
} catch (e) {
console.warn('[AgendaEventoFinanceiroPanel] fetchRecord:', e?.message);
record.value = null;
} finally {
fetching.value = false;
}
}
watch(() => props.evento?.id, () => {
record.value = null
fetchRecord()
}, { immediate: true })
watch(
() => props.evento?.id,
() => {
record.value = null;
fetchRecord();
},
{ immediate: true }
);
// ── gerar cobrança ─────────────────────────────────────────────────────────────
async function onGerarCobranca () {
generating.value = true
try {
const result = await gerarCobrancaManual(props.evento)
if (!result.ok) throw new Error(result.error)
async function onGerarCobranca() {
generating.value = true;
try {
const result = await gerarCobrancaManual(props.evento);
if (!result.ok) throw new Error(result.error);
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'success', summary: 'Cobrança gerada', detail: `${fmtBRL(props.evento.price ?? 0)} agendado para recebimento.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível gerar a cobrança.', life: 4000 })
} finally {
generating.value = false
}
await fetchRecord();
emit('cobranca-atualizada');
toast.add({ severity: 'success', summary: 'Cobrança gerada', detail: `${fmtBRL(props.evento.price ?? 0)} agendado para recebimento.`, life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível gerar a cobrança.', life: 4000 });
} finally {
generating.value = false;
}
}
// ── dialog: registrar pagamento ───────────────────────────────────────────────
const payDlgVisible = ref(false)
const payDlgMethod = ref(null)
const payDlgLoading = ref(false)
const payDlgVisible = ref(false);
const payDlgMethod = ref(null);
const payDlgLoading = ref(false);
function openPayDialog () {
payDlgMethod.value = null
payDlgVisible.value = true
function openPayDialog() {
payDlgMethod.value = null;
payDlgVisible.value = true;
}
async function confirmPayment () {
if (!payDlgMethod.value || !record.value) return
payDlgLoading.value = true
try {
const { data, error } = await supabase.rpc('mark_as_paid', {
p_financial_record_id: record.value.id,
p_payment_method: payDlgMethod.value,
})
if (error) throw error
async function confirmPayment() {
if (!payDlgMethod.value || !record.value) return;
payDlgLoading.value = true;
try {
const { data, error } = await supabase.rpc('mark_as_paid', {
p_financial_record_id: record.value.id,
p_payment_method: payDlgMethod.value
});
if (error) throw error;
payDlgVisible.value = false
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'success', summary: 'Pago!', detail: `Recebimento via ${paymentLabel(payDlgMethod.value)} registrado.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível registrar pagamento.', life: 4000 })
} finally {
payDlgLoading.value = false
}
payDlgVisible.value = false;
await fetchRecord();
emit('cobranca-atualizada');
toast.add({ severity: 'success', summary: 'Pago!', detail: `Recebimento via ${paymentLabel(payDlgMethod.value)} registrado.`, life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível registrar pagamento.', life: 4000 });
} finally {
payDlgLoading.value = false;
}
}
// ── cancelar cobrança ─────────────────────────────────────────────────────────
function requestCancel () {
confirm.require({
message: `Cancelar a cobrança de ${fmtBRL(record.value?.final_amount)} desta sessão?`,
header: 'Cancelar cobrança',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Não',
acceptLabel: 'Sim, cancelar',
acceptSeverity: 'danger',
accept: async () => {
try {
const { error } = await supabase
.from('financial_records')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', record.value.id)
function requestCancel() {
confirm.require({
message: `Cancelar a cobrança de ${fmtBRL(record.value?.final_amount)} desta sessão?`,
header: 'Cancelar cobrança',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Não',
acceptLabel: 'Sim, cancelar',
acceptSeverity: 'danger',
accept: async () => {
try {
const { error } = await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', record.value.id);
if (error) throw error
if (error) throw error;
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'info', summary: 'Cancelado', detail: 'Cobrança cancelada.', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 })
}
},
})
await fetchRecord();
emit('cobranca-atualizada');
toast.add({ severity: 'info', summary: 'Cancelado', detail: 'Cobrança cancelada.', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 });
}
}
});
}
</script>
<template>
<div>
<!-- Painel principal noop (bloqueios) não renderiza nada -->
<div v-if="scenario !== 'noop'" class="fin-panel">
<div>
<!-- Painel principal noop (bloqueios) não renderiza nada -->
<div v-if="scenario !== 'noop'" class="fin-panel">
<!-- Cabeçalho do painel -->
<div class="fin-panel__header">
<i class="pi pi-wallet" />
<span>Cobrança</span>
<Button v-if="props.evento.billed && !fetching" icon="pi pi-refresh" text size="small" severity="secondary" class="ml-auto h-6 w-6" v-tooltip.top="'Recarregar'" @click="fetchRecord" />
</div>
<!-- Cabeçalho do painel -->
<div class="fin-panel__header">
<i class="pi pi-wallet" />
<span>Cobrança</span>
<Button
v-if="props.evento.billed && !fetching"
icon="pi pi-refresh"
text
size="small"
severity="secondary"
class="ml-auto h-6 w-6"
v-tooltip.top="'Recarregar'"
@click="fetchRecord"
/>
</div>
<!-- Sessão de pacote / contrato -->
<div v-if="scenario === 'contrato'" class="fin-panel__body">
<span class="fin-badge fin-badge--contract">
<i class="pi pi-box text-xs" />
Sessão de pacote
</span>
</div>
<!-- Sessão de pacote / contrato -->
<div v-if="scenario === 'contrato'" class="fin-panel__body">
<span class="fin-badge fin-badge--contract">
<i class="pi pi-box text-xs" />
Sessão de pacote
</span>
</div>
<!-- Sem cobrança gerada -->
<div v-else-if="scenario === 'sem-cobranca'" class="fin-panel__body fin-panel__body--empty">
<div class="flex items-center gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-minus-circle text-sm opacity-50" />
<span class="text-sm">Sem cobrança gerada</span>
</div>
<Button label="Gerar cobrança" icon="pi pi-plus" size="small" class="rounded-full mt-2" :loading="generating || finLoading" @click="onGerarCobranca" />
<div v-if="props.evento.price" class="text-xs text-[var(--text-color-secondary)] mt-1">Valor da sessão: {{ fmtBRL(props.evento.price) }}</div>
</div>
<!-- Sem cobrança gerada -->
<div v-else-if="scenario === 'sem-cobranca'" class="fin-panel__body fin-panel__body--empty">
<div class="flex items-center gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-minus-circle text-sm opacity-50" />
<span class="text-sm">Sem cobrança gerada</span>
</div>
<Button
label="Gerar cobrança"
icon="pi pi-plus"
size="small"
class="rounded-full mt-2"
:loading="generating || finLoading"
@click="onGerarCobranca"
/>
<div v-if="props.evento.price" class="text-xs text-[var(--text-color-secondary)] mt-1">
Valor da sessão: {{ fmtBRL(props.evento.price) }}
</div>
</div>
<!-- Carregando o financial_record -->
<div v-else-if="scenario === 'carregando'" class="fin-panel__body">
<div class="flex flex-col gap-1.5">
<Skeleton height="1rem" class="w-24" />
<Skeleton height="1.5rem" class="w-32" />
<Skeleton height="1rem" class="w-20" />
</div>
</div>
<!-- Carregando o financial_record -->
<div v-else-if="scenario === 'carregando'" class="fin-panel__body">
<div class="flex flex-col gap-1.5">
<Skeleton height="1rem" class="w-24" />
<Skeleton height="1.5rem" class="w-32" />
<Skeleton height="1rem" class="w-20" />
</div>
</div>
<!-- Com cobrança -->
<div v-else-if="scenario === 'com-cobranca'" class="fin-panel__body">
<!-- Linha de status + valor -->
<div class="flex items-center justify-between gap-2">
<Tag :value="STATUS_CFG[record.status]?.label ?? record.status" :severity="STATUS_CFG[record.status]?.severity" class="text-xs" />
<span class="font-bold text-sm text-[var(--text-color)]">{{ fmtBRL(record.final_amount) }}</span>
</div>
<!-- Com cobrança -->
<div v-else-if="scenario === 'com-cobranca'" class="fin-panel__body">
<!-- Vencimento / data de pagamento -->
<div class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] mt-1.5">
<template v-if="record.status === 'paid'">
<i class="pi pi-check-circle text-emerald-500" />
<span class="text-emerald-600">{{ paymentLabel(record.payment_method) }} · {{ fmtDate(record.paid_at) }}</span>
</template>
<template v-else>
<i class="pi pi-calendar" />
<span :class="record.status === 'overdue' ? 'text-red-500 font-semibold' : ''"> Vence {{ fmtDate(record.due_date) }} </span>
</template>
</div>
<!-- Linha de status + valor -->
<div class="flex items-center justify-between gap-2">
<Tag
:value="STATUS_CFG[record.status]?.label ?? record.status"
:severity="STATUS_CFG[record.status]?.severity"
class="text-xs"
/>
<span class="font-bold text-sm text-[var(--text-color)]">{{ fmtBRL(record.final_amount) }}</span>
</div>
<!-- Vencimento / data de pagamento -->
<div class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] mt-1.5">
<template v-if="record.status === 'paid'">
<i class="pi pi-check-circle text-emerald-500" />
<span class="text-emerald-600">{{ paymentLabel(record.payment_method) }} · {{ fmtDate(record.paid_at) }}</span>
</template>
<template v-else>
<i class="pi pi-calendar" />
<span :class="record.status === 'overdue' ? 'text-red-500 font-semibold' : ''">
Vence {{ fmtDate(record.due_date) }}
</span>
</template>
</div>
<!-- Ações: pendente / vencido -->
<div v-if="canAct" class="flex gap-1.5 mt-3">
<Button
label="Receber"
icon="pi pi-check"
size="small"
class="rounded-full flex-1"
@click="openPayDialog"
/>
<Button
icon="pi pi-times"
size="small"
severity="danger"
outlined
class="rounded-full h-7 w-7"
v-tooltip.top="'Cancelar cobrança'"
@click="requestCancel"
/>
</div>
</div>
</div>
<!-- Dialog: Registrar Pagamento -->
<Dialog
v-model:visible="payDlgVisible"
modal
:draggable="false"
pt:mask:class="backdrop-blur-xs"
header="Registrar pagamento"
class="w-[92vw] max-w-sm"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Valor -->
<div class="flex items-center justify-between px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<span class="text-sm text-[var(--text-color-secondary)]">Valor a receber</span>
<span class="font-bold text-[var(--text-color)]">{{ fmtBRL(record?.final_amount) }}</span>
</div>
<!-- Método (grid de botões) -->
<div>
<div class="text-sm font-semibold text-[var(--text-color)] mb-2">Método de pagamento</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="opt in PAYMENT_METHODS"
:key="opt.value"
type="button"
class="flex flex-col items-center gap-1 px-2 py-2 rounded-md border text-xs font-medium transition-all duration-150 cursor-pointer select-none"
:class="payDlgMethod === opt.value
? 'border-[var(--primary-color,#6366f1)] bg-[var(--primary-color,#6366f1)]/10 text-[var(--primary-color,#6366f1)]'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-[var(--primary-color,#6366f1)]/40'"
@click="payDlgMethod = opt.value"
>
<i
class="text-base"
:class="{
'pi pi-bolt': opt.value === 'pix',
'pi pi-building': opt.value === 'deposito',
'pi pi-money-bill': opt.value === 'dinheiro',
'pi pi-credit-card': opt.value === 'cartao',
'pi pi-id-card': opt.value === 'convenio',
}"
/>
{{ opt.label }}
</button>
<!-- Ações: pendente / vencido -->
<div v-if="canAct" class="flex gap-1.5 mt-3">
<Button label="Receber" icon="pi pi-check" size="small" class="rounded-full flex-1" @click="openPayDialog" />
<Button icon="pi pi-times" size="small" severity="danger" outlined class="rounded-full h-7 w-7" v-tooltip.top="'Cancelar cobrança'" @click="requestCancel" />
</div>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="payDlgLoading" @click="payDlgVisible = false" />
<Button label="Confirmar" icon="pi pi-check" class="rounded-full" :loading="payDlgLoading" :disabled="!payDlgMethod" @click="confirmPayment" />
</template>
</Dialog>
</div>
<!-- Dialog: Registrar Pagamento -->
<Dialog v-model:visible="payDlgVisible" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Registrar pagamento" class="w-[92vw] max-w-sm">
<div class="flex flex-col gap-4 pt-1">
<!-- Valor -->
<div class="flex items-center justify-between px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<span class="text-sm text-[var(--text-color-secondary)]">Valor a receber</span>
<span class="font-bold text-[var(--text-color)]">{{ fmtBRL(record?.final_amount) }}</span>
</div>
<!-- Método (grid de botões) -->
<div>
<div class="text-sm font-semibold text-[var(--text-color)] mb-2">Método de pagamento</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="opt in PAYMENT_METHODS"
:key="opt.value"
type="button"
class="flex flex-col items-center gap-1 px-2 py-2 rounded-md border text-xs font-medium transition-all duration-150 cursor-pointer select-none"
:class="
payDlgMethod === opt.value
? 'border-[var(--primary-color,#6366f1)] bg-[var(--primary-color,#6366f1)]/10 text-[var(--primary-color,#6366f1)]'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-[var(--primary-color,#6366f1)]/40'
"
@click="payDlgMethod = opt.value"
>
<i
class="text-base"
:class="{
'pi pi-bolt': opt.value === 'pix',
'pi pi-building': opt.value === 'deposito',
'pi pi-money-bill': opt.value === 'dinheiro',
'pi pi-credit-card': opt.value === 'cartao',
'pi pi-id-card': opt.value === 'convenio'
}"
/>
{{ opt.label }}
</button>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="payDlgLoading" @click="payDlgVisible = false" />
<Button label="Confirmar" icon="pi pi-check" class="rounded-full" :loading="payDlgLoading" :disabled="!payDlgMethod" @click="confirmPayment" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.fin-panel {
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 0.5rem;
overflow: hidden;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 0.5rem;
overflow: hidden;
background: var(--surface-card, #fff);
}
.fin-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--surface-ground, #f8fafc);
border-bottom: 1px solid var(--surface-border, #e2e8f0);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color-secondary);
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--surface-ground, #f8fafc);
border-bottom: 1px solid var(--surface-border, #e2e8f0);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.fin-panel__body {
padding: 0.75rem;
padding: 0.75rem;
}
.fin-panel__body--empty {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
.fin-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
}
.fin-badge--contract {
background: color-mix(in srgb, var(--p-indigo-500, #6366f1) 10%, transparent);
color: var(--p-indigo-600, #4f46e5);
border: 1px solid color-mix(in srgb, var(--p-indigo-500, #6366f1) 20%, transparent);
background: color-mix(in srgb, var(--p-indigo-500, #6366f1) 10%, transparent);
color: var(--p-indigo-600, #4f46e5);
border: 1px solid color-mix(in srgb, var(--p-indigo-500, #6366f1) 20%, transparent);
}
</style>
+162 -174
View File
@@ -15,220 +15,208 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref } from 'vue'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import ProgressSpinner from 'primevue/progressspinner';
import { useToast } from 'primevue/usetoast';
import { fetchSlotsRegras } from '@/services/agendaConfigService'
import { fetchSlotsBloqueados, setSlotBloqueado } from '@/services/agendaSlotsBloqueadosService'
import { gerarSlotsDoDia } from '@/utils/slotsGenerator'
import { fetchSlotsRegras } from '@/services/agendaConfigService';
import { fetchSlotsBloqueados, setSlotBloqueado } from '@/services/agendaSlotsBloqueadosService';
import { gerarSlotsDoDia } from '@/utils/slotsGenerator';
import { supabase } from '@/lib/supabase/client'
import { supabase } from '@/lib/supabase/client';
const toast = useToast()
const toast = useToast();
const props = defineProps({
ownerId: { type: String, required: true }
})
ownerId: { type: String, required: true }
});
const diasSemana = [
{ label: 'Seg', value: 1 },
{ label: 'Ter', value: 2 },
{ label: 'Qua', value: 3 },
{ label: 'Qui', value: 4 },
{ label: 'Sex', value: 5 },
{ label: 'Sáb', value: 6 },
{ label: 'Dom', value: 0 }
]
{ label: 'Seg', value: 1 },
{ label: 'Ter', value: 2 },
{ label: 'Qua', value: 3 },
{ label: 'Qui', value: 4 },
{ label: 'Sex', value: 5 },
{ label: 'Sáb', value: 6 },
{ label: 'Dom', value: 0 }
];
const loading = ref(false)
const savingSlot = ref(false)
const loading = ref(false);
const savingSlot = ref(false);
const slotsRegras = ref([]) // agenda_slots_regras
const regrasSemanais = ref([]) // agenda_regras_semanais
const bloqueadosByDia = ref({}) // {dia: Set('09:00'...)}
const slotsRegras = ref([]); // agenda_slots_regras
const regrasSemanais = ref([]); // agenda_regras_semanais
const bloqueadosByDia = ref({}); // {dia: Set('09:00'...)}
async function loadRegrasSemanais() {
const { data, error } = await supabase
.from('agenda_regras_semanais')
.select('*')
.eq('owner_id', props.ownerId)
.order('dia_semana', { ascending: true })
.order('hora_inicio', { ascending: true })
const { data, error } = await supabase.from('agenda_regras_semanais').select('*').eq('owner_id', props.ownerId).order('dia_semana', { ascending: true }).order('hora_inicio', { ascending: true });
if (error) throw error
regrasSemanais.value = data || []
if (error) throw error;
regrasSemanais.value = data || [];
}
async function load() {
loading.value = true
try {
await Promise.all([
loadRegrasSemanais(),
(async () => { slotsRegras.value = await fetchSlotsRegras(props.ownerId) })()
])
loading.value = true;
try {
await Promise.all([
loadRegrasSemanais(),
(async () => {
slotsRegras.value = await fetchSlotsRegras(props.ownerId);
})()
]);
// carregue bloqueados de todos os dias
const map = {}
for (const d of diasSemana.map(x => x.value)) {
const rows = await fetchSlotsBloqueados(props.ownerId, d)
map[d] = new Set(rows.map(r => String(r.hora_inicio).slice(0, 5)))
// carregue bloqueados de todos os dias
const map = {};
for (const d of diasSemana.map((x) => x.value)) {
const rows = await fetchSlotsBloqueados(props.ownerId, d);
map[d] = new Set(rows.map((r) => String(r.hora_inicio).slice(0, 5)));
}
bloqueadosByDia.value = map;
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar a grade.', life: 3200 });
} finally {
loading.value = false;
}
bloqueadosByDia.value = map
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar a grade.', life: 3200 })
} finally {
loading.value = false
}
}
function regraDoDia(dia) {
return slotsRegras.value.find(r => r.dia_semana === dia) || null
return slotsRegras.value.find((r) => r.dia_semana === dia) || null;
}
function janelasDoDia(dia) {
return (regrasSemanais.value || []).filter(r => r.dia_semana === dia && r.ativo !== false)
return (regrasSemanais.value || []).filter((r) => r.dia_semana === dia && r.ativo !== false);
}
function slotsDoDia(dia) {
const regra = regraDoDia(dia)
if (!regra || regra.ativo === false) return []
return gerarSlotsDoDia(janelasDoDia(dia), regra)
const regra = regraDoDia(dia);
if (!regra || regra.ativo === false) return [];
return gerarSlotsDoDia(janelasDoDia(dia), regra);
}
function isBloqueado(dia, hhmm) {
return !!bloqueadosByDia.value?.[dia]?.has(hhmm)
return !!bloqueadosByDia.value?.[dia]?.has(hhmm);
}
async function toggleSlot(dia, hhmm) {
savingSlot.value = true
try {
const blocked = isBloqueado(dia, hhmm)
await setSlotBloqueado(props.ownerId, dia, hhmm, !blocked)
savingSlot.value = true;
try {
const blocked = isBloqueado(dia, hhmm);
await setSlotBloqueado(props.ownerId, dia, hhmm, !blocked);
if (!bloqueadosByDia.value[dia]) bloqueadosByDia.value[dia] = new Set()
if (blocked) bloqueadosByDia.value[dia].delete(hhmm)
else bloqueadosByDia.value[dia].add(hhmm)
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível atualizar o horário.', life: 3200 })
} finally {
savingSlot.value = false
}
if (!bloqueadosByDia.value[dia]) bloqueadosByDia.value[dia] = new Set();
if (blocked) bloqueadosByDia.value[dia].delete(hhmm);
else bloqueadosByDia.value[dia].add(hhmm);
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível atualizar o horário.', life: 3200 });
} finally {
savingSlot.value = false;
}
}
const resumo = computed(() => {
// só um resumo simples — depois refinamos
const diasAtivos = diasSemana.filter(d => (regraDoDia(d.value)?.ativo !== false)).length
return { diasAtivos }
})
// só um resumo simples — depois refinamos
const diasAtivos = diasSemana.filter((d) => regraDoDia(d.value)?.ativo !== false).length;
return { diasAtivos };
});
onMounted(load)
onMounted(load);
</script>
<template>
<Card class="overflow-hidden">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-globe" />
<span>Grade do online (estilo Altegio)</span>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
</div>
</div>
</template>
<template #content>
<div v-if="loading" class="flex items-center gap-3 text-600">
<ProgressSpinner style="width:22px;height:22px" />
Carregando
</div>
<div v-else>
<!-- Resumo tipo "cards" -->
<div class="grid grid-cols-12 gap-3 mb-4">
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Tipo de slots</div>
<div class="text-900 font-semibold mt-1">Fixo</div>
</div>
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Dias ativos</div>
<div class="text-900 font-semibold mt-1">{{ resumo.diasAtivos }}</div>
</div>
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Dica</div>
<div class="text-900 font-semibold mt-1">Clique em um horário para ocultar/exibir</div>
</div>
</div>
<TabView>
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
<div class="grid grid-cols-12 gap-4">
<!-- Jornada -->
<div class="col-span-12">
<div class="flex items-center justify-between">
<div class="text-900 font-medium">Jornada do dia</div>
<div class="text-600 text-sm">
(Isso vem das suas "janelas semanais")
</div>
<Card class="overflow-hidden">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-globe" />
<span>Grade do online (estilo Altegio)</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<template v-if="janelasDoDia(d.value).length">
<Tag
v-for="j in janelasDoDia(d.value)"
:key="j.id"
:value="`${String(j.hora_inicio).slice(0,5)}${String(j.hora_fim).slice(0,5)}`"
/>
</template>
<template v-else>
<span class="text-600 text-sm">Sem jornada ativa neste dia.</span>
</template>
<div class="flex gap-2">
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
</div>
</div>
<!-- Chips -->
<div class="col-span-12">
<div class="flex items-center justify-between">
<div class="text-900 font-medium">Horários publicados</div>
<div class="text-600 text-sm" v-if="savingSlot">Salvando</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<template v-if="slotsDoDia(d.value).length">
<button
v-for="hh in slotsDoDia(d.value)"
:key="hh"
class="px-3 py-2 rounded-lg border text-sm transition"
:class="isBloqueado(d.value, hh)
? 'border-[var(--surface-border)] text-600 bg-[var(--surface-ground)] line-through opacity-70'
: 'border-[var(--surface-border)] text-900 bg-[var(--surface-card)] hover:bg-[var(--surface-ground)]'"
@click="toggleSlot(d.value, hh)"
>
{{ hh }}
</button>
</template>
<template v-else>
<span class="text-600 text-sm">
Nada para publicar (verifique: jornada do dia + regra de slots ativa).
</span>
</template>
</div>
<div class="text-600 text-sm mt-3 leading-relaxed">
Se algum horário não deve aparecer para o paciente, clique para <b>desativar</b>.
Isso não altera sua agenda interna a disponibilidade do online.
</div>
</div>
</div>
</TabPanel>
</TabView>
</div>
</template>
</Card>
</template>
<template #content>
<div v-if="loading" class="flex items-center gap-3 text-600">
<ProgressSpinner style="width: 22px; height: 22px" />
Carregando
</div>
<div v-else>
<!-- Resumo tipo "cards" -->
<div class="grid grid-cols-12 gap-3 mb-4">
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Tipo de slots</div>
<div class="text-900 font-semibold mt-1">Fixo</div>
</div>
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Dias ativos</div>
<div class="text-900 font-semibold mt-1">{{ resumo.diasAtivos }}</div>
</div>
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Dica</div>
<div class="text-900 font-semibold mt-1">Clique em um horário para ocultar/exibir</div>
</div>
</div>
<TabView>
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
<div class="grid grid-cols-12 gap-4">
<!-- Jornada -->
<div class="col-span-12">
<div class="flex items-center justify-between">
<div class="text-900 font-medium">Jornada do dia</div>
<div class="text-600 text-sm">(Isso vem das suas "janelas semanais")</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<template v-if="janelasDoDia(d.value).length">
<Tag v-for="j in janelasDoDia(d.value)" :key="j.id" :value="`${String(j.hora_inicio).slice(0, 5)}${String(j.hora_fim).slice(0, 5)}`" />
</template>
<template v-else>
<span class="text-600 text-sm">Sem jornada ativa neste dia.</span>
</template>
</div>
</div>
<!-- Chips -->
<div class="col-span-12">
<div class="flex items-center justify-between">
<div class="text-900 font-medium">Horários publicados</div>
<div class="text-600 text-sm" v-if="savingSlot">Salvando</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<template v-if="slotsDoDia(d.value).length">
<button
v-for="hh in slotsDoDia(d.value)"
:key="hh"
class="px-3 py-2 rounded-lg border text-sm transition"
:class="
isBloqueado(d.value, hh)
? 'border-[var(--surface-border)] text-600 bg-[var(--surface-ground)] line-through opacity-70'
: 'border-[var(--surface-border)] text-900 bg-[var(--surface-card)] hover:bg-[var(--surface-ground)]'
"
@click="toggleSlot(d.value, hh)"
>
{{ hh }}
</button>
</template>
<template v-else>
<span class="text-600 text-sm"> Nada para publicar (verifique: jornada do dia + regra de slots ativa). </span>
</template>
</div>
<div class="text-600 text-sm mt-3 leading-relaxed">Se algum horário não deve aparecer para o paciente, clique para <b>desativar</b>. Isso não altera sua agenda interna a disponibilidade do online.</div>
</div>
</div>
</TabPanel>
</TabView>
</div>
</template>
</Card>
</template>
@@ -0,0 +1,298 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaQuickAddDialog.vue
| Data: 2026
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { useServices } from '@/features/agenda/composables/useServices';
import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans';
const props = defineProps({
modelValue: { type: Boolean, default: false },
type: { type: String, default: 'servico' },
procedurePlanId: { type: String, default: null }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const { load: loadServices, save: saveService } = useServices();
const { plans, save: savePlan, savePlanService, load: loadInsurancePlans } = useInsurancePlans();
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
});
const ownerId = ref(null);
const saving = ref(false);
const addingProcedures = ref(false);
const selectedPlan = ref(null);
const newPlanId = ref(null);
const isServico = computed(() => props.type === 'servico');
const isConvenio = computed(() => props.type === 'convenio');
const isProcedureOnly = computed(() => !!props.procedurePlanId);
const servicoForm = ref({ name: '', price: null });
const convenioForm = ref({ name: '', notes: '' });
const proceduresForm = ref({ name: '', value: null });
async function loadOwnerId() {
if (ownerId.value) return;
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
if (uid) ownerId.value = uid;
}
async function resetForms() {
servicoForm.value = { name: '', price: null };
convenioForm.value = { name: '', notes: '' };
proceduresForm.value = { name: '', value: null };
selectedPlan.value = null;
newPlanId.value = null;
addingProcedures.value = false;
}
watch(
() => props.modelValue,
async (open) => {
if (!open) return;
await nextTick();
await loadOwnerId();
resetForms();
if (isServico.value) {
await loadServices(ownerId.value);
} else if (isConvenio.value || isProcedureOnly.value) {
await loadInsurancePlans(ownerId.value);
if (isProcedureOnly.value) {
selectedPlan.value = plans.value.find((p) => p.id === props.procedurePlanId);
}
}
}
);
async function handleSaveServico() {
if (!servicoForm.value.name?.trim()) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Nome do serviço é obrigatório.', life: 3000 });
return;
}
if (servicoForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Preço é obrigatório.', life: 3000 });
return;
}
saving.value = true;
try {
await saveService({
owner_id: ownerId.value,
tenant_id: tenantStore.activeTenantId || null,
name: servicoForm.value.name.trim(),
description: null,
price: servicoForm.value.price,
duration_min: null
});
toast.add({ severity: 'success', summary: 'Criado', detail: 'Serviço criado com sucesso.', life: 3000 });
emit('created', { type: 'servico' });
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar.', life: 4000 });
} finally {
saving.value = false;
}
}
async function handleSaveConvenio() {
if (!convenioForm.value.name?.trim()) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Nome do convênio é obrigatório.', life: 3000 });
return;
}
saving.value = true;
try {
const newPlan = await savePlan({
owner_id: ownerId.value,
tenant_id: tenantStore.activeTenantId || null,
name: convenioForm.value.name.trim(),
notes: convenioForm.value.notes?.trim() || null
});
toast.add({ severity: 'success', summary: 'Criado', detail: 'Convênio criado com sucesso.', life: 3000 });
newPlanId.value = newPlan?.id || null;
selectedPlan.value = plans.value.find((p) => p.id === newPlan?.id);
await loadInsurancePlans(ownerId.value);
if (selectedPlan.value) {
addingProcedures.value = true;
} else {
emit('created', { type: 'convenio' });
visible.value = false;
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar.', life: 4000 });
} finally {
saving.value = false;
}
}
async function handleSaveProcedure() {
if (!proceduresForm.value.name?.trim()) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Nome do procedimento é obrigatório.', life: 3000 });
return;
}
if (proceduresForm.value.value == null) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Valor é obrigatório.', life: 3000 });
return;
}
const planId = selectedPlan.value?.id || newPlanId.value;
if (!planId) {
toast.add({ severity: 'error', summary: 'Erro', detail: 'Convênio não encontrado.', life: 4000 });
return;
}
saving.value = true;
try {
await savePlanService({
insurance_plan_id: planId,
name: proceduresForm.value.name.trim(),
value: proceduresForm.value.value
});
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Procedimento adicionado.', life: 3000 });
emit('created', { type: 'convenio' });
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao adicionar.', life: 4000 });
} finally {
saving.value = false;
}
}
function skipProcedures() {
emit('created', { type: 'convenio' });
visible.value = false;
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0" :style="{ background: isServico ? '#6366f1' : isProcedureOnly ? '#f59e0b' : '#22c55e' }">
<i :class="isServico ? 'pi pi-tag' : isProcedureOnly ? 'pi pi-list' : 'pi pi-id-card'" />
</div>
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ isServico ? 'Novo Serviço' : addingProcedures || isProcedureOnly ? 'Adicionar Procedimento' : 'Novo Convênio' }}
</div>
<div class="text-xs opacity-50">
{{ isServico ? 'Cadastro rápido de serviço' : addingProcedures || isProcedureOnly ? 'Do convênio ' + (selectedPlan?.name || '') : 'Cadastro rápido de convênio' }}
</div>
</div>
</div>
</div>
</template>
<!-- Formulário Serviço -->
<div v-if="isServico" class="flex flex-col gap-4">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText id="qadd-svc-name" v-model="servicoForm.name" class="w-full" variant="filled" :disabled="saving" />
</IconField>
<label for="qadd-svc-name">Nome do serviço *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber id="qadd-svc-price" v-model="servicoForm.price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid :disabled="saving" />
<label for="qadd-svc-price">Preço (R$) *</label>
</FloatLabel>
<Message severity="info" :closable="false">
<span class="text-sm">O serviço estará disponível para seleção na agenda após ser criado.</span>
</Message>
</div>
<!-- Formulário Convênio / Procedimento -->
<div v-else-if="isConvenio || isProcedureOnly" class="flex flex-col gap-4">
<template v-if="!addingProcedures && !isProcedureOnly">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputText id="qadd-cnv-name" v-model="convenioForm.name" class="w-full" variant="filled" :disabled="saving" />
</IconField>
<label for="qadd-cnv-name">Nome do convênio *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputText id="qadd-cnv-notes" v-model="convenioForm.notes" class="w-full" variant="filled" :disabled="saving" />
<label for="qadd-cnv-notes">Observações (opcional)</label>
</FloatLabel>
<Message severity="info" :closable="false">
<span class="text-sm">Após criar o convênio, você pode adicionar procedimentos.</span>
</Message>
</template>
<template v-if="addingProcedures || isProcedureOnly">
<div v-if="!isProcedureOnly" class="flex items-center gap-2 p-3 rounded-lg bg-surface-100">
<i class="pi pi-check-circle text-green-500" />
<span class="text-sm"
>Convênio <strong>{{ selectedPlan?.name || convenioForm.name }}</strong> criado!</span
>
</div>
<FloatLabel variant="on">
<InputText id="qadd-proc-name" v-model="proceduresForm.name" class="w-full" variant="filled" :disabled="saving" />
<label for="qadd-proc-name">Nome do procedimento *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber id="qadd-proc-value" v-model="proceduresForm.value" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid :disabled="saving" />
<label for="qadd-proc-value">Valor (R$) *</label>
</FloatLabel>
<div v-if="!isProcedureOnly" class="flex items-center gap-2">
<Button label="Pular" severity="secondary" text size="small" :disabled="saving" @click="skipProcedures" />
</div>
</template>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="saving" @click="visible = false" />
<Button v-if="isServico" label="Criar Serviço" icon="pi pi-check" class="rounded-full" :loading="saving" @click="handleSaveServico" />
<template v-else-if="isConvenio || isProcedureOnly">
<Button v-if="!addingProcedures && !isProcedureOnly" label="Criar Convênio" icon="pi pi-check" class="rounded-full" :loading="saving" @click="handleSaveConvenio" />
<Button v-else label="Adicionar" icon="pi pi-check" class="rounded-full" :loading="saving" @click="handleSaveProcedure" />
</template>
</div>
</template>
</Dialog>
</template>
+140 -140
View File
@@ -15,181 +15,181 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Dropdown from 'primevue/dropdown'
import InputNumber from 'primevue/inputnumber'
import InputSwitch from 'primevue/inputswitch'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watch, onMounted } from 'vue';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Dropdown from 'primevue/dropdown';
import InputNumber from 'primevue/inputnumber';
import InputSwitch from 'primevue/inputswitch';
import { useToast } from 'primevue/usetoast';
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService'
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService';
const toast = useToast()
const toast = useToast();
const props = defineProps({
ownerId: { type: String, required: true }
})
ownerId: { type: String, required: true }
});
const loading = ref(false)
const saving = ref(false)
const loading = ref(false);
const saving = ref(false);
const diasSemana = [
{ label: 'Dom', value: 0 },
{ label: 'Seg', value: 1 },
{ label: 'Ter', value: 2 },
{ label: 'Qua', value: 3 },
{ label: 'Qui', value: 4 },
{ label: 'Sex', value: 5 },
{ label: 'Sáb', value: 6 }
]
{ label: 'Dom', value: 0 },
{ label: 'Seg', value: 1 },
{ label: 'Ter', value: 2 },
{ label: 'Qua', value: 3 },
{ label: 'Qui', value: 4 },
{ label: 'Sex', value: 5 },
{ label: 'Sáb', value: 6 }
];
const passos = [15, 20, 30, 45, 60, 75, 90, 120].map(v => ({ label: `${v} min`, value: v }))
const offsets = [0, 15, 30, 45].map(v => ({ label: v === 0 ? ':00' : `:${String(v).padStart(2, '0')}`, value: v }))
const passos = [15, 20, 30, 45, 60, 75, 90, 120].map((v) => ({ label: `${v} min`, value: v }));
const offsets = [0, 15, 30, 45].map((v) => ({ label: v === 0 ? ':00' : `:${String(v).padStart(2, '0')}`, value: v }));
const model = ref({
0: { dia_semana: 0, ativo: false, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
1: { dia_semana: 1, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
2: { dia_semana: 2, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
3: { dia_semana: 3, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
4: { dia_semana: 4, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
5: { dia_semana: 5, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
6: { dia_semana: 6, ativo: true, passo_minutos: 60, offset_minutos: 30, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 }
})
0: { dia_semana: 0, ativo: false, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
1: { dia_semana: 1, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
2: { dia_semana: 2, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
3: { dia_semana: 3, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
4: { dia_semana: 4, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
5: { dia_semana: 5, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
6: { dia_semana: 6, ativo: true, passo_minutos: 60, offset_minutos: 30, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 }
});
function applyRows(rows) {
for (const r of rows || []) {
model.value[r.dia_semana] = {
dia_semana: r.dia_semana,
ativo: !!r.ativo,
passo_minutos: r.passo_minutos,
offset_minutos: r.offset_minutos,
buffer_antes_min: r.buffer_antes_min,
buffer_depois_min: r.buffer_depois_min,
min_antecedencia_horas: r.min_antecedencia_horas
for (const r of rows || []) {
model.value[r.dia_semana] = {
dia_semana: r.dia_semana,
ativo: !!r.ativo,
passo_minutos: r.passo_minutos,
offset_minutos: r.offset_minutos,
buffer_antes_min: r.buffer_antes_min,
buffer_depois_min: r.buffer_depois_min,
min_antecedencia_horas: r.min_antecedencia_horas
};
}
}
}
async function load() {
loading.value = true
try {
const rows = await fetchSlotsRegras(props.ownerId)
applyRows(rows)
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar slots por dia.', life: 3200 })
} finally {
loading.value = false
}
loading.value = true;
try {
const rows = await fetchSlotsRegras(props.ownerId);
applyRows(rows);
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar slots por dia.', life: 3200 });
} finally {
loading.value = false;
}
}
async function salvarDia(dia) {
saving.value = true
try {
const p = model.value[dia]
await upsertSlotRegra(props.ownerId, p)
toast.add({ severity: 'success', summary: 'Salvo', detail: `Slots do ${diasSemana.find(x => x.value === dia)?.label} atualizados.`, life: 1600 })
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar.', life: 3200 })
} finally {
saving.value = false
}
saving.value = true;
try {
const p = model.value[dia];
await upsertSlotRegra(props.ownerId, p);
toast.add({ severity: 'success', summary: 'Salvo', detail: `Slots do ${diasSemana.find((x) => x.value === dia)?.label} atualizados.`, life: 1600 });
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar.', life: 3200 });
} finally {
saving.value = false;
}
}
async function salvarTudo() {
saving.value = true
try {
for (const d of diasSemana.map(x => x.value)) {
await upsertSlotRegra(props.ownerId, model.value[d])
saving.value = true;
try {
for (const d of diasSemana.map((x) => x.value)) {
await upsertSlotRegra(props.ownerId, model.value[d]);
}
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slots por dia atualizados.', life: 1800 });
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar tudo.', life: 3200 });
} finally {
saving.value = false;
}
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slots por dia atualizados.', life: 1800 })
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar tudo.', life: 3200 })
} finally {
saving.value = false
}
}
onMounted(load)
onMounted(load);
</script>
<template>
<Card class="overflow-hidden">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-clock" />
<span>Organização de slots (por dia)</span>
</div>
<Card class="overflow-hidden">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-clock" />
<span>Organização de slots (por dia)</span>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
<Button label="Salvar tudo" icon="pi pi-check" size="small" :loading="saving" @click="salvarTudo" />
</div>
</div>
</template>
<div class="flex gap-2">
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
<Button label="Salvar tudo" icon="pi pi-check" size="small" :loading="saving" @click="salvarTudo" />
</div>
</div>
</template>
<template #content>
<div class="text-600 text-sm mb-3 leading-relaxed">
Aqui você define <b>de quanto em quanto</b> os horários aparecem e <b>em qual minuto</b> eles alinham (ex.: :00 ou :30).
<span class="ml-1">Ex.: sábado com passo 60 e offset 30 gera 08:30, 09:30, 10:30</span>
</div>
<TabView>
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 flex items-center gap-3">
<InputSwitch v-model="model[d.value].ativo" />
<div>
<div class="text-900 font-medium">Ativo</div>
<div class="text-600 text-sm">Se desligado, o online não oferece horários nesse dia.</div>
</div>
<template #content>
<div class="text-600 text-sm mb-3 leading-relaxed">
Aqui você define <b>de quanto em quanto</b> os horários aparecem e <b>em qual minuto</b> eles alinham (ex.: :00 ou :30).
<span class="ml-1">Ex.: sábado com passo 60 e offset 30 gera 08:30, 09:30, 10:30</span>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<Dropdown v-model="model[d.value].passo_minutos" :options="passos" optionLabel="label" optionValue="value" class="w-full" inputId="passo" />
<label for="passo">Passo (min)</label>
</FloatLabel>
</div>
<TabView>
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 flex items-center gap-3">
<InputSwitch v-model="model[d.value].ativo" />
<div>
<div class="text-900 font-medium">Ativo</div>
<div class="text-600 text-sm">Se desligado, o online não oferece horários nesse dia.</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<Dropdown v-model="model[d.value].offset_minutos" :options="offsets" optionLabel="label" optionValue="value" class="w-full" inputId="offset" />
<label for="offset">Alinhamento</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<Dropdown v-model="model[d.value].passo_minutos" :options="passos" optionLabel="label" optionValue="value" class="w-full" inputId="passo" />
<label for="passo">Passo (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].min_antecedencia_horas" class="w-full" :min="0" :max="720" inputId="ante" />
<label for="ante">Antecedência (h)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<Dropdown v-model="model[d.value].offset_minutos" :options="offsets" optionLabel="label" optionValue="value" class="w-full" inputId="offset" />
<label for="offset">Alinhamento</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].buffer_antes_min" class="w-full" :min="0" :max="240" inputId="ba" />
<label for="ba">Buffer antes (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].min_antecedencia_horas" class="w-full" :min="0" :max="720" inputId="ante" />
<label for="ante">Antecedência (h)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].buffer_depois_min" class="w-full" :min="0" :max="240" inputId="bd" />
<label for="bd">Buffer depois (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].buffer_antes_min" class="w-full" :min="0" :max="240" inputId="ba" />
<label for="ba">Buffer antes (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4 flex items-end">
<Button class="w-full" label="Salvar este dia" icon="pi pi-check" :loading="saving" @click="salvarDia(d.value)" />
</div>
</div>
</TabPanel>
</TabView>
</template>
</Card>
<div class="col-span-12 md:col-span-4">
<FloatLabel>
<InputNumber v-model="model[d.value].buffer_depois_min" class="w-full" :min="0" :max="240" inputId="bd" />
<label for="bd">Buffer depois (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-4 flex items-end">
<Button class="w-full" label="Salvar este dia" icon="pi pi-check" :loading="saving" @click="salvarDia(d.value)" />
</div>
</div>
</TabPanel>
</TabView>
</template>
</Card>
</template>
+169 -192
View File
@@ -15,256 +15,233 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch } from 'vue'
import DatePicker from 'primevue/datepicker'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watch } from 'vue';
import DatePicker from 'primevue/datepicker';
import { useToast } from 'primevue/usetoast';
const toast = useToast()
const toast = useToast();
const props = defineProps({
modelValue: { type: Array, default: () => [] } // [{id,label,inicio,fim}]
})
const emit = defineEmits(['update:modelValue'])
modelValue: { type: Array, default: () => [] } // [{id,label,inicio,fim}]
});
const emit = defineEmits(['update:modelValue']);
function isValidHHMM(v) {
return /^\d{2}:\d{2}$/.test(String(v || '').trim())
return /^\d{2}:\d{2}$/.test(String(v || '').trim());
}
function hhmmToMin(hhmm) {
const [h, m] = String(hhmm).split(':').map(Number)
return h * 60 + m
const [h, m] = String(hhmm).split(':').map(Number);
return h * 60 + m;
}
function minToHHMM(min) {
const h = Math.floor(min / 60) % 24
const m = min % 60
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')
const h = Math.floor(min / 60) % 24;
const m = min % 60;
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
}
function newId() {
return Math.random().toString(16).slice(2) + Date.now().toString(16)
return Math.random().toString(16).slice(2) + Date.now().toString(16);
}
function hhmmToDate(hhmm) {
if (!isValidHHMM(hhmm)) return null
const [h, m] = String(hhmm).split(':').map(Number)
const d = new Date(); d.setHours(h, m, 0, 0); return d
if (!isValidHHMM(hhmm)) return null;
const [h, m] = String(hhmm).split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d;
}
function dateToHHMM(date) {
if (!date || !(date instanceof Date)) return null
return String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0')
if (!date || !(date instanceof Date)) return null;
return String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0');
}
const internal = ref([])
const internal = ref([]);
watch(
() => props.modelValue,
(v) => {
internal.value = (Array.isArray(v) ? v : []).map(p => ({
id: p.id || newId(),
label: String(p.label || 'Pausa'),
inicio: String(p.inicio || '').slice(0, 5),
fim: String(p.fim || '').slice(0, 5)
}))
},
{ immediate: true, deep: true }
)
() => props.modelValue,
(v) => {
internal.value = (Array.isArray(v) ? v : []).map((p) => ({
id: p.id || newId(),
label: String(p.label || 'Pausa'),
inicio: String(p.inicio || '').slice(0, 5),
fim: String(p.fim || '').slice(0, 5)
}));
},
{ immediate: true, deep: true }
);
function pushUpdate(next) {
// ordena por início
const sorted = [...next].sort((a, b) => hhmmToMin(a.inicio) - hhmmToMin(b.inicio))
internal.value = sorted
emit('update:modelValue', sorted)
// ordena por início
const sorted = [...next].sort((a, b) => hhmmToMin(a.inicio) - hhmmToMin(b.inicio));
internal.value = sorted;
emit('update:modelValue', sorted);
}
// união de intervalos existentes
function normalizeIntervals(list) {
const intervals = (list || [])
.map(p => ({ s: hhmmToMin(p.inicio), e: hhmmToMin(p.fim) }))
.sort((a, b) => a.s - b.s)
const intervals = (list || []).map((p) => ({ s: hhmmToMin(p.inicio), e: hhmmToMin(p.fim) })).sort((a, b) => a.s - b.s);
const merged = []
for (const it of intervals) {
if (!merged.length) merged.push({ ...it })
else {
const last = merged[merged.length - 1]
if (it.s <= last.e) last.e = Math.max(last.e, it.e)
else merged.push({ ...it })
const merged = [];
for (const it of intervals) {
if (!merged.length) merged.push({ ...it });
else {
const last = merged[merged.length - 1];
if (it.s <= last.e) last.e = Math.max(last.e, it.e);
else merged.push({ ...it });
}
}
}
return merged
return merged;
}
// retorna "sobras" de [s,e] depois de remover intervalos ocupados
function subtractIntervals(s, e, occupiedMerged) {
let segments = [{ s, e }]
for (const occ of occupiedMerged) {
const next = []
for (const seg of segments) {
// sem interseção
if (seg.e <= occ.s || seg.s >= occ.e) {
next.push(seg)
continue
}
// corta esquerda
if (seg.s < occ.s) next.push({ s: seg.s, e: Math.min(occ.s, seg.e) })
// corta direita
if (seg.e > occ.e) next.push({ s: Math.max(occ.e, seg.s), e: seg.e })
let segments = [{ s, e }];
for (const occ of occupiedMerged) {
const next = [];
for (const seg of segments) {
// sem interseção
if (seg.e <= occ.s || seg.s >= occ.e) {
next.push(seg);
continue;
}
// corta esquerda
if (seg.s < occ.s) next.push({ s: seg.s, e: Math.min(occ.s, seg.e) });
// corta direita
if (seg.e > occ.e) next.push({ s: Math.max(occ.e, seg.s), e: seg.e });
}
segments = next;
if (!segments.length) break;
}
segments = next
if (!segments.length) break
}
// remove segmentos muito pequenos (0)
return segments.filter(x => x.e > x.s)
// remove segmentos muito pequenos (0)
return segments.filter((x) => x.e > x.s);
}
function addPauseSmart({ label, inicio, fim }) {
if (!isValidHHMM(inicio) || !isValidHHMM(fim)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Horários devem estar em HH:MM.', life: 2200 })
return
}
if (fim <= inicio) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'O fim deve ser maior que o início.', life: 2200 })
return
}
if (!isValidHHMM(inicio) || !isValidHHMM(fim)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Horários devem estar em HH:MM.', life: 2200 });
return;
}
if (fim <= inicio) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'O fim deve ser maior que o início.', life: 2200 });
return;
}
const s = hhmmToMin(inicio)
const e = hhmmToMin(fim)
const s = hhmmToMin(inicio);
const e = hhmmToMin(fim);
const occupied = normalizeIntervals(internal.value)
const segments = subtractIntervals(s, e, occupied)
const occupied = normalizeIntervals(internal.value);
const segments = subtractIntervals(s, e, occupied);
if (!segments.length) {
toast.add({ severity: 'info', summary: 'Nada a adicionar', detail: 'Esse período já está coberto por outra pausa.', life: 2400 })
return
}
if (!segments.length) {
toast.add({ severity: 'info', summary: 'Nada a adicionar', detail: 'Esse período já está coberto por outra pausa.', life: 2400 });
return;
}
const toAdd = segments.map(seg => ({
id: newId(),
label: label || 'Pausa',
inicio: minToHHMM(seg.s),
fim: minToHHMM(seg.e)
}))
const toAdd = segments.map((seg) => ({
id: newId(),
label: label || 'Pausa',
inicio: minToHHMM(seg.s),
fim: minToHHMM(seg.e)
}));
// se houve "recorte", avisa
if (segments.length !== 1 || (segments[0].s !== s || segments[0].e !== e)) {
toast.add({
severity: 'info',
summary: 'Ajuste automático',
detail: 'Havia conflito com outra pausa; adicionei apenas o trecho que não sobrepõe.',
life: 3200
})
}
// se houve "recorte", avisa
if (segments.length !== 1 || segments[0].s !== s || segments[0].e !== e) {
toast.add({
severity: 'info',
summary: 'Ajuste automático',
detail: 'Havia conflito com outra pausa; adicionei apenas o trecho que não sobrepõe.',
life: 3200
});
}
pushUpdate([...internal.value, ...toAdd])
pushUpdate([...internal.value, ...toAdd]);
}
function removePause(id) {
pushUpdate(internal.value.filter(p => p.id !== id))
pushUpdate(internal.value.filter((p) => p.id !== id));
}
// ======================================================
// UI: presets + custom dialog
// ======================================================
const presets = [
{ label: 'Almoço', inicio: '12:00', fim: '13:00' },
{ label: 'Almoço', inicio: '13:00', fim: '14:00' },
{ label: 'Janta', inicio: '18:00', fim: '19:00' }
]
{ label: 'Almoço', inicio: '12:00', fim: '13:00' },
{ label: 'Almoço', inicio: '13:00', fim: '14:00' },
{ label: 'Janta', inicio: '18:00', fim: '19:00' }
];
const dlg = ref(false)
const form = ref({ label: 'Pausa', inicio: null, fim: null })
const dlg = ref(false);
const form = ref({ label: 'Pausa', inicio: null, fim: null });
const formInicioHHMM = computed(() => dateToHHMM(form.value.inicio))
const formFimHHMM = computed(() => dateToHHMM(form.value.fim))
const formValid = computed(() =>
isValidHHMM(formInicioHHMM.value) &&
isValidHHMM(formFimHHMM.value) &&
formFimHHMM.value > formInicioHHMM.value
)
const formInicioHHMM = computed(() => dateToHHMM(form.value.inicio));
const formFimHHMM = computed(() => dateToHHMM(form.value.fim));
const formValid = computed(() => isValidHHMM(formInicioHHMM.value) && isValidHHMM(formFimHHMM.value) && formFimHHMM.value > formInicioHHMM.value);
function openCustom() {
form.value = { label: 'Pausa', inicio: hhmmToDate('12:00'), fim: hhmmToDate('13:00') }
dlg.value = true
form.value = { label: 'Pausa', inicio: hhmmToDate('12:00'), fim: hhmmToDate('13:00') };
dlg.value = true;
}
function saveCustom() {
addPauseSmart({ label: form.value.label, inicio: formInicioHHMM.value, fim: formFimHHMM.value })
dlg.value = false
addPauseSmart({ label: form.value.label, inicio: formInicioHHMM.value, fim: formFimHHMM.value });
dlg.value = false;
}
</script>
<template>
<div class="space-y-2">
<!-- actions -->
<div class="flex flex-wrap gap-2 items-center">
<Button
v-for="(p, idx) in presets"
:key="'pre_'+idx"
size="small"
severity="secondary"
outlined
icon="pi pi-plus"
:label="`${p.label} (${p.inicio}${p.fim})`"
@click="addPauseSmart(p)"
/>
<Button size="small" icon="pi pi-sliders-h" label="Customizar" @click="openCustom" />
<div class="space-y-2">
<!-- actions -->
<div class="flex flex-wrap gap-2 items-center">
<Button v-for="(p, idx) in presets" :key="'pre_' + idx" size="small" severity="secondary" outlined icon="pi pi-plus" :label="`${p.label} (${p.inicio}${p.fim})`" @click="addPauseSmart(p)" />
<Button size="small" icon="pi pi-sliders-h" label="Customizar" @click="openCustom" />
</div>
<Divider class="my-2" />
<!-- chips/badges -->
<div v-if="!internal.length" class="text-600 text-sm">Nenhuma pausa adicionada.</div>
<div v-else class="flex flex-wrap gap-2">
<div v-for="p in internal" :key="p.id" class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-1">
<Tag :value="p.label" severity="secondary" />
<span class="text-600 text-sm">{{ p.inicio }}{{ p.fim }}</span>
<Button icon="pi pi-times" text rounded severity="danger" @click="removePause(p.id)" />
</div>
</div>
<!-- custom dialog -->
<Dialog v-model:visible="dlg" modal :draggable="false" header="Adicionar pausa" :style="{ width: '420px' }">
<div class="flex flex-col gap-4">
<div>
<label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Nome</label>
<InputText v-model="form.label" class="w-full" placeholder="Ex.: Almoço" />
</div>
<div class="flex gap-3">
<div class="flex-1 flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Início</label>
<DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
<div class="flex-1 flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Fim</label>
<DatePicker v-model="form.fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
</div>
<div v-if="formInicioHHMM && formFimHHMM && formFimHHMM <= formInicioHHMM" class="text-sm text-red-500">O fim precisa ser maior que o início.</div>
<div class="text-[var(--text-color-secondary)] text-xs">Se houver conflito com outra pausa, o sistema adiciona apenas o trecho que não sobrepõe.</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlg = false" />
<Button label="Adicionar" icon="pi pi-check" :disabled="!formValid" @click="saveCustom" />
</template>
</Dialog>
</div>
<Divider class="my-2" />
<!-- chips/badges -->
<div v-if="!internal.length" class="text-600 text-sm">
Nenhuma pausa adicionada.
</div>
<div v-else class="flex flex-wrap gap-2">
<div
v-for="p in internal"
:key="p.id"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-1"
>
<Tag :value="p.label" severity="secondary" />
<span class="text-600 text-sm">{{ p.inicio }}{{ p.fim }}</span>
<Button icon="pi pi-times" text rounded severity="danger" @click="removePause(p.id)" />
</div>
</div>
<!-- custom dialog -->
<Dialog v-model:visible="dlg" modal :draggable="false" header="Adicionar pausa" :style="{ width: '420px' }">
<div class="flex flex-col gap-4">
<div>
<label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Nome</label>
<InputText v-model="form.label" class="w-full" placeholder="Ex.: Almoço" />
</div>
<div class="flex gap-3">
<div class="flex-1 flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Início</label>
<DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
<div class="flex-1 flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Fim</label>
<DatePicker v-model="form.fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
</div>
<div v-if="formInicioHHMM && formFimHHMM && formFimHHMM <= formInicioHHMM" class="text-sm text-red-500">
O fim precisa ser maior que o início.
</div>
<div class="text-[var(--text-color-secondary)] text-xs">
Se houver conflito com outra pausa, o sistema adiciona apenas o trecho que não sobrepõe.
</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlg = false" />
<Button label="Adicionar" icon="pi pi-check" :disabled="!formValid" @click="saveCustom" />
</template>
</Dialog>
</div>
</template>
</template>
+257 -190
View File
@@ -16,269 +16,336 @@
-->
<script setup>
import { computed } from 'vue'
import { computed } from 'vue';
const props = defineProps({
cfg: { type: Object, required: true }
})
cfg: { type: Object, required: true }
});
const cor = computed(() => props.cfg.cor_primaria || '#4b6bff')
const cor = computed(() => props.cfg.cor_primaria || '#4b6bff');
const corMix = computed(() => `color-mix(in srgb, ${cor.value} 15%, transparent)`)
const corMix = computed(() => `color-mix(in srgb, ${cor.value} 15%, transparent)`);
const tipos = [
{ key: 'primeira', label: 'Primeira Entrevista', sub: 'Novo paciente', icon: 'pi-star', bg: '#0284c7', shadow: 'rgba(2,132,199,.25)' },
{ key: 'retorno', label: 'Retorno', sub: 'Já sou paciente', icon: 'pi-refresh', bg: '#059669', shadow: 'rgba(5,150,105,.25)' },
{ key: 'reagendar',label: 'Reagendar', sub: 'Mudar data ou horário', icon: 'pi-calendar-plus', bg: '#7c3aed', shadow: 'rgba(124,58,237,.25)' },
]
{ key: 'primeira', label: 'Primeira Entrevista', sub: 'Novo paciente', icon: 'pi-star', bg: '#0284c7', shadow: 'rgba(2,132,199,.25)' },
{ key: 'retorno', label: 'Retorno', sub: 'Já sou paciente', icon: 'pi-refresh', bg: '#059669', shadow: 'rgba(5,150,105,.25)' },
{ key: 'reagendar', label: 'Reagendar', sub: 'Mudar data ou horário', icon: 'pi-calendar-plus', bg: '#7c3aed', shadow: 'rgba(124,58,237,.25)' }
];
const tiposAtivos = computed(() =>
tipos.filter(t => props.cfg.tipos_habilitados?.includes(t.key))
)
const tiposAtivos = computed(() => tipos.filter((t) => props.cfg.tipos_habilitados?.includes(t.key)));
const modalidadeLabel = computed(() => ({
presencial: 'Presencial',
online: 'Online (vídeo)',
ambos: 'Presencial · Online',
}[props.cfg.modalidade] || 'Presencial'))
const modalidadeLabel = computed(
() =>
({
presencial: 'Presencial',
online: 'Online (vídeo)',
ambos: 'Presencial · Online'
})[props.cfg.modalidade] || 'Presencial'
);
</script>
<template>
<!-- Frame de celular -->
<div class="phone-frame">
<div class="phone-notch" />
<!-- Frame de celular -->
<div class="phone-frame">
<div class="phone-notch" />
<!-- Root do agendador -->
<div class="agdp-root">
<!-- Root do agendador -->
<div class="agdp-root">
<!-- Card principal -->
<div class="agdp-card" :style="{ '--cp': cor }">
<!-- Hero -->
<div class="agdp-hero">
<!-- Blobs -->
<div class="agdp-blobs" aria-hidden="true">
<div class="agdp-blob agdp-blob--1" :style="{ background: `color-mix(in srgb, ${cor} 22%, transparent)` }" />
<div class="agdp-blob agdp-blob--2" :style="{ background: `color-mix(in srgb, ${cor} 12%, transparent)` }" />
</div>
<!-- Card principal -->
<div class="agdp-card" :style="{ '--cp': cor }">
<!-- Logo -->
<div class="agdp-avatar">
<img v-if="cfg.logomarca_url" :src="cfg.logomarca_url" alt="logo" class="w-full h-full object-cover" />
<i v-else class="pi pi-heart text-xl" :style="{ color: cor }" />
</div>
<!-- Hero -->
<div class="agdp-hero">
<!-- Blobs -->
<div class="agdp-blobs" aria-hidden="true">
<div class="agdp-blob agdp-blob--1" :style="{ background: `color-mix(in srgb, ${cor} 22%, transparent)` }" />
<div class="agdp-blob agdp-blob--2" :style="{ background: `color-mix(in srgb, ${cor} 12%, transparent)` }" />
</div>
<!-- Nome -->
<div class="agdp-name">
{{ cfg.nome_exibicao || 'Seu nome aqui' }}
</div>
<!-- Logo -->
<div class="agdp-avatar">
<img v-if="cfg.logomarca_url" :src="cfg.logomarca_url" alt="logo" class="w-full h-full object-cover" />
<i v-else class="pi pi-heart text-xl" :style="{ color: cor }" />
</div>
<!-- Endereço -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-endereco">
<i class="pi pi-map-marker text-[0.6rem]" />
<span>{{ cfg.endereco }}</span>
</div>
<!-- Nome -->
<div class="agdp-name">
{{ cfg.nome_exibicao || 'Seu nome aqui' }}
</div>
<!-- Modalidade badge -->
<div class="agdp-badge" :style="{ background: corMix, color: cor }">
<i class="pi pi-video text-[0.55rem]" />
{{ modalidadeLabel }}
</div>
</div>
<!-- Endereço -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-endereco">
<i class="pi pi-map-marker text-[0.6rem]" />
<span>{{ cfg.endereco }}</span>
</div>
<!-- Home section -->
<div class="agdp-section">
<!-- Mensagem de boas-vindas -->
<p class="agdp-welcome">
{{ cfg.mensagem_boas_vindas || 'Bem-vindo! Escolha o tipo de consulta para começar.' }}
</p>
<!-- Modalidade badge -->
<div class="agdp-badge" :style="{ background: corMix, color: cor }">
<i class="pi pi-video text-[0.55rem]" />
{{ modalidadeLabel }}
</div>
</div>
<!-- Como chegar -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-como-chegar">
<i class="pi pi-directions text-[0.6rem]" />
<span :style="{ color: cor }">Como chegar</span>
</div>
<!-- Home section -->
<div class="agdp-section">
<div class="agdp-divider" />
<!-- Mensagem de boas-vindas -->
<p class="agdp-welcome">
{{ cfg.mensagem_boas_vindas || 'Bem-vindo! Escolha o tipo de consulta para começar.' }}
</p>
<!-- Botões de tipo -->
<div class="agdp-tipos">
<div v-for="t in tiposAtivos" :key="t.key" class="agdp-tipo-btn" :style="{ '--btn-bg': t.bg, '--btn-shadow': t.shadow }">
<div class="agdp-tipo-icon">
<i :class="`pi ${t.icon} text-[0.65rem]`" />
</div>
<div class="agdp-tipo-label">
<span class="agdp-tipo-main">{{ t.label }}</span>
<span class="agdp-tipo-sub">{{ t.sub }}</span>
</div>
<i class="pi pi-arrow-right text-[0.55rem] opacity-40 ml-auto" />
</div>
<!-- Como chegar -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-como-chegar">
<i class="pi pi-directions text-[0.6rem]" />
<span :style="{ color: cor }">Como chegar</span>
</div>
<!-- Placeholder se nenhum tipo ativo -->
<div v-if="!tiposAtivos.length" class="agdp-no-tipos">Nenhum tipo de consulta habilitado</div>
</div>
<div class="agdp-divider" />
<!-- Botões de tipo -->
<div class="agdp-tipos">
<div
v-for="t in tiposAtivos"
:key="t.key"
class="agdp-tipo-btn"
:style="{ '--btn-bg': t.bg, '--btn-shadow': t.shadow }"
>
<div class="agdp-tipo-icon">
<i :class="`pi ${t.icon} text-[0.65rem]`" />
</div>
<div class="agdp-tipo-label">
<span class="agdp-tipo-main">{{ t.label }}</span>
<span class="agdp-tipo-sub">{{ t.sub }}</span>
</div>
<i class="pi pi-arrow-right text-[0.55rem] opacity-40 ml-auto" />
<!-- Powered -->
<p class="agdp-powered">Powered by <strong>Agência Psi</strong></p>
</div>
</div>
<!-- Placeholder se nenhum tipo ativo -->
<div v-if="!tiposAtivos.length" class="agdp-no-tipos">
Nenhum tipo de consulta habilitado
</div>
</div>
<!-- Powered -->
<p class="agdp-powered">Powered by <strong>Agência Psi</strong></p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Frame de celular ──────────────────── */
.phone-frame {
position: relative;
width: 260px;
min-height: 500px;
margin: 0 auto;
border-radius: 2.5rem;
border: 8px solid #1e293b;
background: #1e293b;
box-shadow:
0 0 0 2px #334155,
0 32px 64px rgba(0,0,0,.35),
0 8px 24px rgba(0,0,0,.2);
overflow: hidden;
position: relative;
width: 260px;
min-height: 500px;
margin: 0 auto;
border-radius: 2.5rem;
border: 8px solid #1e293b;
background: #1e293b;
box-shadow:
0 0 0 2px #334155,
0 32px 64px rgba(0, 0, 0, 0.35),
0 8px 24px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.phone-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 72px;
height: 10px;
background: #1e293b;
border-radius: 0 0 10px 10px;
z-index: 10;
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 72px;
height: 10px;
background: #1e293b;
border-radius: 0 0 10px 10px;
z-index: 10;
}
/* ── Root ──────────────────────────────── */
.agdp-root {
background: #f0f3fb;
min-height: 100%;
padding: 12px 10px 16px;
overflow-y: auto;
max-height: 560px;
background: #f0f3fb;
min-height: 100%;
padding: 12px 10px 16px;
overflow-y: auto;
max-height: 560px;
}
/* ── Card ──────────────────────────────── */
.agdp-card {
background: #fff;
border-radius: 1.25rem;
border: 1px solid rgba(0,0,0,.06);
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.04);
overflow: hidden;
background: #fff;
border-radius: 1.25rem;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
/* ── Hero ──────────────────────────────── */
.agdp-hero {
position: relative;
overflow: hidden;
padding: 1.25rem 1rem 0.875rem;
background: #f7f8fd;
border-bottom: 1px solid rgba(0,0,0,.06);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
position: relative;
overflow: hidden;
padding: 1.25rem 1rem 0.875rem;
background: #f7f8fd;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.agdp-blobs {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.agdp-blob {
position: absolute;
border-radius: 50%;
filter: blur(40px);
}
.agdp-blob--1 {
width: 8rem;
height: 8rem;
top: -2rem;
right: -2rem;
}
.agdp-blob--2 {
width: 7rem;
height: 7rem;
bottom: -2rem;
left: -2rem;
}
.agdp-blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.agdp-blob { position: absolute; border-radius: 50%; filter: blur(40px); }
.agdp-blob--1 { width: 8rem; height: 8rem; top: -2rem; right: -2rem; }
.agdp-blob--2 { width: 7rem; height: 7rem; bottom: -2rem; left: -2rem; }
.agdp-avatar {
position: relative; z-index: 1;
width: 52px; height: 52px;
border-radius: 50%;
border: 2.5px solid #fff;
box-shadow: 0 4px 12px rgba(0,0,0,.12);
background: #f1f5f9;
overflow: hidden;
display: grid; place-items: center;
flex-shrink: 0;
position: relative;
z-index: 1;
width: 52px;
height: 52px;
border-radius: 50%;
border: 2.5px solid #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
background: #f1f5f9;
overflow: hidden;
display: grid;
place-items: center;
flex-shrink: 0;
}
.agdp-name {
position: relative; z-index: 1;
font-size: 0.82rem; font-weight: 800;
letter-spacing: -0.02em;
color: #111827;
text-align: center;
position: relative;
z-index: 1;
font-size: 0.82rem;
font-weight: 800;
letter-spacing: -0.02em;
color: #111827;
text-align: center;
}
.agdp-endereco {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 3px;
font-size: 0.62rem; color: #6b7280;
text-align: center;
max-width: 90%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 3px;
font-size: 0.62rem;
color: #6b7280;
text-align: center;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agdp-badge {
position: relative; z-index: 1;
font-size: 0.58rem; font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
display: flex; align-items: center; gap: 3px;
position: relative;
z-index: 1;
font-size: 0.58rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
display: flex;
align-items: center;
gap: 3px;
}
/* ── Section ───────────────────────────── */
.agdp-section { padding: 0.875rem 0.875rem 0.75rem; }
.agdp-section {
padding: 0.875rem 0.875rem 0.75rem;
}
.agdp-welcome {
font-size: 0.68rem; color: #6b7280;
text-align: center; line-height: 1.5;
margin-bottom: 0.6rem;
font-size: 0.68rem;
color: #6b7280;
text-align: center;
line-height: 1.5;
margin-bottom: 0.6rem;
}
.agdp-como-chegar {
display: flex; align-items: center; justify-content: center; gap: 3px;
font-size: 0.62rem; font-weight: 700;
margin-bottom: 0.6rem;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 0.62rem;
font-weight: 700;
margin-bottom: 0.6rem;
}
.agdp-divider {
height: 1px; background: #e5e7eb; margin-bottom: 0.75rem;
height: 1px;
background: #e5e7eb;
margin-bottom: 0.75rem;
}
/* ── Tipos ─────────────────────────────── */
.agdp-tipos { display: flex; flex-direction: column; gap: 0.5rem; }
.agdp-tipos {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.agdp-tipo-btn {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-radius: 0.75rem;
border: 1.5px solid #e5e7eb;
background: #fff;
cursor: default;
transition: border-color .15s, box-shadow .15s;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 0.75rem;
border: 1.5px solid #e5e7eb;
background: #fff;
cursor: default;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.agdp-tipo-icon {
width: 28px; height: 28px;
border-radius: 0.5rem;
display: grid; place-items: center;
background: color-mix(in srgb, var(--btn-bg, #6366f1) 15%, transparent);
color: var(--btn-bg, #6366f1);
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 0.5rem;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--btn-bg, #6366f1) 15%, transparent);
color: var(--btn-bg, #6366f1);
flex-shrink: 0;
}
.agdp-tipo-label {
display: flex;
flex-direction: column;
gap: 1px;
flex: 1;
min-width: 0;
}
.agdp-tipo-main {
font-size: 0.72rem;
font-weight: 700;
color: #111827;
}
.agdp-tipo-sub {
font-size: 0.58rem;
color: #9ca3af;
}
.agdp-tipo-label { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.agdp-tipo-main { font-size: 0.72rem; font-weight: 700; color: #111827; }
.agdp-tipo-sub { font-size: 0.58rem; color: #9ca3af; }
.agdp-no-tipos {
font-size: 0.68rem; color: #9ca3af;
text-align: center; padding: 1rem 0;
font-size: 0.68rem;
color: #9ca3af;
text-align: center;
padding: 1rem 0;
}
.agdp-powered {
text-align: center; font-size: 0.58rem; color: #9ca3af;
margin-top: 1rem;
text-align: center;
font-size: 0.58rem;
color: #9ca3af;
margin-top: 1rem;
}
.agdp-powered strong {
color: #6b7280;
font-weight: 700;
}
.agdp-powered strong { color: #6b7280; font-weight: 700; }
</style>
@@ -15,250 +15,220 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '@/stores/notificationStore'
import NotificationItem from './NotificationItem.vue'
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useNotificationStore } from '@/stores/notificationStore';
import NotificationItem from './NotificationItem.vue';
const store = useNotificationStore()
const router = useRouter()
const store = useNotificationStore();
const router = useRouter();
const filter = ref('unread') // 'unread' | 'all'
const filter = ref('unread'); // 'unread' | 'all'
const drawerOpen = computed({
get: () => store.drawerOpen,
set: (v) => { store.drawerOpen = v }
})
get: () => store.drawerOpen,
set: (v) => {
store.drawerOpen = v;
}
});
const displayedItems = computed(() =>
filter.value === 'unread' ? store.unreadItems : store.allItems
)
const displayedItems = computed(() => (filter.value === 'unread' ? store.unreadItems : store.allItems));
function handleRead (id) {
store.markRead(id)
// Fecha o drawer e deixa a navegação acontecer
store.drawerOpen = false
function handleRead(id) {
store.markRead(id);
// Fecha o drawer e deixa a navegação acontecer
store.drawerOpen = false;
}
function handleArchive (id) {
store.archive(id)
function handleArchive(id) {
store.archive(id);
}
function goToHistory () {
router.push('/therapist/notificacoes')
store.drawerOpen = false
function goToHistory() {
router.push('/therapist/notificacoes');
store.drawerOpen = false;
}
</script>
<template>
<Drawer
v-model:visible="drawerOpen"
position="right"
:style="{ width: '380px' }"
:pt="{ header: { class: 'notification-drawer__header' } }"
>
<!-- Header -->
<template #header>
<div class="notification-drawer__header-content">
<span class="notification-drawer__title">Notificações</span>
<Badge
v-if="store.unreadCount > 0"
:value="store.unreadCount > 99 ? '99+' : store.unreadCount"
severity="danger"
/>
</div>
</template>
<Drawer v-model:visible="drawerOpen" position="right" :style="{ width: '380px' }" :pt="{ header: { class: 'notification-drawer__header' } }">
<!-- Header -->
<template #header>
<div class="notification-drawer__header-content">
<span class="notification-drawer__title">Notificações</span>
<Badge v-if="store.unreadCount > 0" :value="store.unreadCount > 99 ? '99+' : store.unreadCount" severity="danger" />
</div>
</template>
<!-- Corpo -->
<div class="notification-drawer__body">
<!-- Ação em lote -->
<div class="notification-drawer__toolbar">
<!-- Filtro tabs -->
<div class="notification-drawer__tabs">
<button
class="notification-drawer__tab"
:class="{ 'notification-drawer__tab--active': filter === 'unread' }"
@click="filter = 'unread'"
>
Não lidas
<span v-if="store.unreadCount > 0" class="notification-drawer__tab-count">
{{ store.unreadCount }}
</span>
</button>
<button
class="notification-drawer__tab"
:class="{ 'notification-drawer__tab--active': filter === 'all' }"
@click="filter = 'all'"
>
Todas
</button>
<!-- Corpo -->
<div class="notification-drawer__body">
<!-- Ação em lote -->
<div class="notification-drawer__toolbar">
<!-- Filtro tabs -->
<div class="notification-drawer__tabs">
<button class="notification-drawer__tab" :class="{ 'notification-drawer__tab--active': filter === 'unread' }" @click="filter = 'unread'">
Não lidas
<span v-if="store.unreadCount > 0" class="notification-drawer__tab-count">
{{ store.unreadCount }}
</span>
</button>
<button class="notification-drawer__tab" :class="{ 'notification-drawer__tab--active': filter === 'all' }" @click="filter = 'all'">Todas</button>
</div>
<Button v-if="store.unreadCount > 0" link size="small" label="Marcar todas como lidas" @click="store.markAllRead()" class="notification-drawer__mark-all" />
</div>
<!-- Lista -->
<div v-if="displayedItems.length > 0" class="notification-drawer__list">
<NotificationItem v-for="item in displayedItems" :key="item.id" :item="item" @read="handleRead" @archive="handleArchive" />
</div>
<!-- Empty state -->
<div v-else class="notification-drawer__empty">
<i class="pi pi-bell-slash notification-drawer__empty-icon" />
<p class="notification-drawer__empty-text">Tudo em dia por aqui 🎉</p>
<p class="notification-drawer__empty-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
</div>
</div>
<Button
v-if="store.unreadCount > 0"
link
size="small"
label="Marcar todas como lidas"
@click="store.markAllRead()"
class="notification-drawer__mark-all"
/>
</div>
<!-- Lista -->
<div v-if="displayedItems.length > 0" class="notification-drawer__list">
<NotificationItem
v-for="item in displayedItems"
:key="item.id"
:item="item"
@read="handleRead"
@archive="handleArchive"
/>
</div>
<!-- Empty state -->
<div v-else class="notification-drawer__empty">
<i class="pi pi-bell-slash notification-drawer__empty-icon" />
<p class="notification-drawer__empty-text">Tudo em dia por aqui 🎉</p>
<p class="notification-drawer__empty-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
</div>
</div>
<!-- Footer -->
<template #footer>
<div class="notification-drawer__footer">
<button class="notification-drawer__history-link" @click="goToHistory">
<i class="pi pi-history" />
Ver histórico completo
</button>
</div>
</template>
</Drawer>
<!-- Footer -->
<template #footer>
<div class="notification-drawer__footer">
<button class="notification-drawer__history-link" @click="goToHistory">
<i class="pi pi-history" />
Ver histórico completo
</button>
</div>
</template>
</Drawer>
</template>
<style scoped>
.notification-drawer__header-content {
display: flex;
align-items: center;
gap: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.notification-drawer__title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.notification-drawer__body {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.notification-drawer__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--surface-border);
flex-shrink: 0;
gap: 0.5rem;
flex-wrap: wrap;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--surface-border);
flex-shrink: 0;
gap: 0.5rem;
flex-wrap: wrap;
}
.notification-drawer__tabs {
display: flex;
gap: 0.25rem;
display: flex;
gap: 0.25rem;
}
.notification-drawer__tab {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
font-size: 0.8rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.notification-drawer__tab--active {
background: var(--primary-color);
color: var(--primary-color-text);
border-color: var(--primary-color);
background: var(--primary-color);
color: var(--primary-color-text);
border-color: var(--primary-color);
}
.notification-drawer__tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
padding: 0 0.25rem;
border-radius: 999px;
background: rgba(255,255,255,0.25);
font-size: 0.7rem;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
padding: 0 0.25rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
font-size: 0.7rem;
font-weight: 700;
}
.notification-drawer__mark-all {
white-space: nowrap;
font-size: 0.78rem !important;
padding: 0 !important;
white-space: nowrap;
font-size: 0.78rem !important;
padding: 0 !important;
}
.notification-drawer__list {
flex: 1;
overflow-y: auto;
flex: 1;
overflow-y: auto;
}
.notification-drawer__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
text-align: center;
}
.notification-drawer__empty-icon {
font-size: 2.5rem;
color: var(--text-color-secondary);
opacity: 0.4;
font-size: 2.5rem;
color: var(--text-color-secondary);
opacity: 0.4;
}
.notification-drawer__empty-text {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.notification-drawer__empty-sub {
font-size: 0.82rem;
color: var(--text-color-secondary);
margin: 0;
font-size: 0.82rem;
color: var(--text-color-secondary);
margin: 0;
}
.notification-drawer__footer {
display: flex;
justify-content: center;
padding: 0.75rem 1rem;
border-top: 1px solid var(--surface-border);
display: flex;
justify-content: center;
padding: 0.75rem 1rem;
border-top: 1px solid var(--surface-border);
}
.notification-drawer__history-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: var(--primary-color);
font-size: 0.85rem;
cursor: pointer;
padding: 0;
transition: opacity 0.15s;
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: var(--primary-color);
font-size: 0.85rem;
cursor: pointer;
padding: 0;
transition: opacity 0.15s;
}
.notification-drawer__history-link:hover {
opacity: 0.75;
text-decoration: underline;
opacity: 0.75;
text-decoration: underline;
}
</style>
+131 -147
View File
@@ -15,205 +15,189 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { formatDistanceToNow } from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { useNotificationStore } from '@/stores/notificationStore'
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { useNotificationStore } from '@/stores/notificationStore';
const props = defineProps({
item: { type: Object, required: true }
})
item: { type: Object, required: true }
});
const emit = defineEmits(['read', 'archive'])
const emit = defineEmits(['read', 'archive']);
const router = useRouter()
const store = useNotificationStore()
const router = useRouter();
const store = useNotificationStore();
const typeMap = {
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500', },
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500', },
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500', },
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500', },
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500' },
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500' },
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500' },
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500' }
};
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' });
const isUnread = computed(() => !props.item.read_at);
const timeAgo = computed(() => formatDistanceToNow(new Date(props.item.created_at), { addSuffix: true, locale: ptBR }));
const initials = computed(() => props.item.payload?.avatar_initials || '?');
function handleRowClick() {
const deeplink = props.item.payload?.deeplink;
if (deeplink) {
router.push(deeplink);
store.drawerOpen = false;
emit('read', props.item.id);
}
}
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' })
const isUnread = computed(() => !props.item.read_at)
const timeAgo = computed(() =>
formatDistanceToNow(new Date(props.item.created_at), { addSuffix: true, locale: ptBR })
)
const initials = computed(() => props.item.payload?.avatar_initials || '?')
function handleRowClick () {
const deeplink = props.item.payload?.deeplink
if (deeplink) {
router.push(deeplink)
store.drawerOpen = false
emit('read', props.item.id)
}
function handleMarkRead(e) {
e.stopPropagation();
emit('read', props.item.id);
}
function handleMarkRead (e) {
e.stopPropagation()
emit('read', props.item.id)
}
function handleArchive (e) {
e.stopPropagation()
emit('archive', props.item.id)
function handleArchive(e) {
e.stopPropagation();
emit('archive', props.item.id);
}
</script>
<template>
<div
class="notif-item"
:class="[meta.border, isUnread ? 'notif-item--unread' : '']"
role="button"
tabindex="0"
@click="handleRowClick"
@keydown.enter="handleRowClick"
>
<!-- Ícone do tipo -->
<div class="notif-item__icon" aria-hidden="true">
<i :class="['pi', meta.icon]" />
</div>
<div class="notif-item" :class="[meta.border, isUnread ? 'notif-item--unread' : '']" role="button" tabindex="0" @click="handleRowClick" @keydown.enter="handleRowClick">
<!-- Ícone do tipo -->
<div class="notif-item__icon" aria-hidden="true">
<i :class="['pi', meta.icon]" />
</div>
<!-- Avatar -->
<div class="notif-item__avatar" aria-hidden="true">
{{ initials }}
</div>
<!-- Avatar -->
<div class="notif-item__avatar" aria-hidden="true">
{{ initials }}
</div>
<!-- Conteúdo -->
<div class="notif-item__body">
<p class="notif-item__title">{{ item.payload?.title }}</p>
<p class="notif-item__detail">{{ item.payload?.detail }}</p>
<p class="notif-item__time">{{ timeAgo }}</p>
</div>
<!-- Conteúdo -->
<div class="notif-item__body">
<p class="notif-item__title">{{ item.payload?.title }}</p>
<p class="notif-item__detail">{{ item.payload?.detail }}</p>
<p class="notif-item__time">{{ timeAgo }}</p>
</div>
<!-- Ações -->
<div class="notif-item__actions" @click.stop>
<button
v-if="isUnread"
class="notif-item__btn"
title="Marcar como lida"
@click="handleMarkRead"
>
<i class="pi pi-check" />
</button>
<button
class="notif-item__btn"
title="Arquivar"
@click="handleArchive"
>
<i class="pi pi-times" />
</button>
<!-- Ações -->
<div class="notif-item__actions" @click.stop>
<button v-if="isUnread" class="notif-item__btn" title="Marcar como lida" @click="handleMarkRead">
<i class="pi pi-check" />
</button>
<button class="notif-item__btn" title="Arquivar" @click="handleArchive">
<i class="pi pi-times" />
</button>
</div>
</div>
</div>
</template>
<style scoped>
.notif-item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-left-width: 3px;
border-left-style: solid;
border-bottom: 1px solid var(--surface-border);
background: transparent;
cursor: pointer;
transition: background 0.15s;
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-left-width: 3px;
border-left-style: solid;
border-bottom: 1px solid var(--surface-border);
background: transparent;
cursor: pointer;
transition: background 0.15s;
}
.notif-item:hover {
background: var(--surface-hover);
background: var(--surface-hover);
}
.notif-item--unread {
background: rgba(99, 102, 241, 0.05);
background: rgba(99, 102, 241, 0.05);
}
.notif-item--unread:hover {
background: rgba(99, 102, 241, 0.09);
background: rgba(99, 102, 241, 0.09);
}
.notif-item__icon {
flex-shrink: 0;
display: flex;
align-items: center;
padding-top: 0.15rem;
font-size: 0.9rem;
color: var(--text-color-secondary);
flex-shrink: 0;
display: flex;
align-items: center;
padding-top: 0.15rem;
font-size: 0.9rem;
color: var(--text-color-secondary);
}
.notif-item__avatar {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #38bdf8);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #38bdf8);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
display: flex;
align-items: center;
justify-content: center;
}
.notif-item__body {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.notif-item__title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-color);
margin: 0 0 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
font-size: 0.85rem;
color: var(--text-color);
margin: 0 0 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notif-item__detail {
font-size: 0.78rem;
color: var(--text-color-secondary);
margin: 0 0 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.78rem;
color: var(--text-color-secondary);
margin: 0 0 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notif-item__time {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.7;
margin: 0;
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.7;
margin: 0;
}
.notif-item__actions {
display: flex;
align-items: center;
gap: 0.125rem;
opacity: 0;
transition: opacity 0.15s;
display: flex;
align-items: center;
gap: 0.125rem;
opacity: 0;
transition: opacity 0.15s;
}
.notif-item:hover .notif-item__actions {
opacity: 1;
opacity: 1;
}
.notif-item__btn {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.75rem;
transition:
background 0.15s,
color 0.15s;
}
.notif-item__btn:hover {
background: var(--surface-border);
color: var(--text-color);
background: var(--surface-border);
color: var(--text-color);
}
</style>
+144 -173
View File
@@ -15,220 +15,191 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import { usePatientLifecycle } from '@/composables/usePatientLifecycle'
import { ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import { usePatientLifecycle } from '@/composables/usePatientLifecycle';
const props = defineProps({
patient: { type: Object, required: true },
hasHistory: { type: Boolean, default: false }
})
patient: { type: Object, required: true },
hasHistory: { type: Boolean, default: false }
});
const emit = defineEmits(['updated'])
const emit = defineEmits(['updated']);
const toast = useToast()
const { deletePatient, deactivatePatient, archivePatient, reactivatePatient } = usePatientLifecycle()
const toast = useToast();
const { deletePatient, deactivatePatient, archivePatient, reactivatePatient } = usePatientLifecycle();
const loading = ref(false)
const menu = ref()
const loading = ref(false);
const menu = ref();
// ── Dialogs ────────────────────────────────────────────────
const deactivateDialogOpen = ref(false)
const archiveDialogOpen = ref(false)
const hasHistoryDialogOpen = ref(false)
const deactivateDialogOpen = ref(false);
const archiveDialogOpen = ref(false);
const hasHistoryDialogOpen = ref(false);
// ── Helpers ───────────────────────────────────────────────
function showSuccess (detail) {
toast.add({ severity: 'success', summary: 'Ok', detail, life: 2500 })
function showSuccess(detail) {
toast.add({ severity: 'success', summary: 'Ok', detail, life: 2500 });
}
function showError (detail) {
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4000 })
function showError(detail) {
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4000 });
}
async function runAction (fn, successMsg) {
loading.value = true
try {
const result = await fn(props.patient.id)
if (!result.ok) { showError(result.message || result.error?.message || 'Falha ao executar ação.'); return }
showSuccess(successMsg)
emit('updated')
} catch (e) {
showError(e?.message || 'Falha inesperada.')
} finally {
loading.value = false
}
async function runAction(fn, successMsg) {
loading.value = true;
try {
const result = await fn(props.patient.id);
if (!result.ok) {
showError(result.message || result.error?.message || 'Falha ao executar ação.');
return;
}
showSuccess(successMsg);
emit('updated');
} catch (e) {
showError(e?.message || 'Falha inesperada.');
} finally {
loading.value = false;
}
}
// ── Desativar — sempre mostra dialog informativo
function handleDeactivate () {
deactivateDialogOpen.value = true
function handleDeactivate() {
deactivateDialogOpen.value = true;
}
async function confirmDeactivate () {
deactivateDialogOpen.value = false
await runAction(deactivatePatient, 'Paciente desativado.')
async function confirmDeactivate() {
deactivateDialogOpen.value = false;
await runAction(deactivatePatient, 'Paciente desativado.');
}
// ── Arquivar — sempre mostra dialog informativo
function handleArchive () {
archiveDialogOpen.value = true
function handleArchive() {
archiveDialogOpen.value = true;
}
async function confirmArchive () {
archiveDialogOpen.value = false
hasHistoryDialogOpen.value = false
await runAction(archivePatient, 'Paciente arquivado.')
async function confirmArchive() {
archiveDialogOpen.value = false;
hasHistoryDialogOpen.value = false;
await runAction(archivePatient, 'Paciente arquivado.');
}
// ── Excluir — se tem histórico, mostra dialog informativo
function handleDelete () {
if (props.hasHistory) {
hasHistoryDialogOpen.value = true
return
}
doDelete()
function handleDelete() {
if (props.hasHistory) {
hasHistoryDialogOpen.value = true;
return;
}
doDelete();
}
async function doDelete () {
loading.value = true
try {
const result = await deletePatient(props.patient.id)
if (!result.ok) { showError(result.message || 'Não foi possível excluir.'); return }
showSuccess('Paciente excluído.')
emit('updated')
} catch (e) {
showError(e?.message || 'Falha inesperada.')
} finally {
loading.value = false
}
async function doDelete() {
loading.value = true;
try {
const result = await deletePatient(props.patient.id);
if (!result.ok) {
showError(result.message || 'Não foi possível excluir.');
return;
}
showSuccess('Paciente excluído.');
emit('updated');
} catch (e) {
showError(e?.message || 'Falha inesperada.');
} finally {
loading.value = false;
}
}
// ── Menu de ações ─────────────────────────────────────────
function menuItems () {
const status = props.patient.status
const items = []
function menuItems() {
const status = props.patient.status;
const items = [];
if (status === 'Ativo') {
items.push({ label: 'Desativar', icon: 'pi pi-pause', command: () => handleDeactivate() })
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
}
if (status === 'Ativo') {
items.push({ label: 'Desativar', icon: 'pi pi-pause', command: () => handleDeactivate() });
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() });
}
if (status === 'Inativo') {
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
}
if (status === 'Inativo') {
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') });
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() });
}
if (status === 'Arquivado') {
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
}
if (status === 'Arquivado') {
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') });
}
if (status === 'Alta' || status === 'Encaminhado') {
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
}
if (status === 'Alta' || status === 'Encaminhado') {
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() });
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') });
}
if (items.length) items.push({ separator: true })
items.push({ label: 'Excluir paciente', icon: 'pi pi-trash', class: 'text-red-500', command: () => handleDelete() })
if (items.length) items.push({ separator: true });
items.push({ label: 'Excluir paciente', icon: 'pi pi-trash', class: 'text-red-500', command: () => handleDelete() });
return items
return items;
}
</script>
<template>
<div class="flex gap-1 justify-end">
<Button
icon="pi pi-ellipsis-v"
severity="secondary"
outlined
size="small"
:loading="loading"
v-tooltip.top="'Ações do paciente'"
@click="(e) => menu.toggle(e)"
/>
<Menu ref="menu" :model="menuItems()" popup appendTo="body" />
</div>
<!-- Dialog: Desativar -->
<Dialog
v-model:visible="deactivateDialogOpen"
modal
:draggable="false"
header="Desativar paciente"
:style="{ width: '460px', maxWidth: '95vw' }"
>
<div class="flex gap-3 items-start py-2">
<i class="pi pi-exclamation-triangle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
<p class="m-0 font-semibold">
Atenção: este paciente pode possuir sessões agendadas e/ou recorrências ativas.
</p>
<p class="m-0">
Ao desativar, todas as sessões e recorrências serão mantidas na agenda porém novos agendamentos ficarão bloqueados.
</p>
<p class="m-0 text-[var(--text-color-secondary)]">
Recomendamos revisar a agenda e encerrar as recorrências manualmente antes de prosseguir.
</p>
</div>
<div class="flex gap-1 justify-end">
<Button icon="pi pi-ellipsis-v" severity="secondary" outlined size="small" :loading="loading" v-tooltip.top="'Ações do paciente'" @click="(e) => menu.toggle(e)" />
<Menu ref="menu" :model="menuItems()" popup appendTo="body" />
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="deactivateDialogOpen = false" />
<Button label="Entendo, desativar mesmo assim!" icon="pi pi-pause" severity="warn" @click="confirmDeactivate" />
</div>
</template>
</Dialog>
<!-- Dialog: Arquivar -->
<Dialog
v-model:visible="archiveDialogOpen"
modal
:draggable="false"
header="Arquivar paciente"
:style="{ width: '460px', maxWidth: '95vw' }"
>
<div class="flex gap-3 items-start py-2">
<i class="pi pi-archive text-slate-500 text-2xl mt-0.5 flex-shrink-0" />
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
<p class="m-0 font-semibold">O que acontece ao arquivar um paciente?</p>
<ul class="m-0 pl-4 space-y-1 text-[var(--text-color)]">
<li>O paciente sairá da listagem ativa e ficará oculto nas buscas padrão.</li>
<li>Todo o histórico clínico, sessões e registros financeiros serão preservados.</li>
<li>Novos agendamentos para este paciente ficarão bloqueados.</li>
<li>O paciente pode ser <strong>reativado</strong> a qualquer momento pelo menu de ações.</li>
</ul>
<p class="m-0 text-[var(--text-color-secondary)]">
Arquivar é indicado para pacientes que concluíram o acompanhamento ou que estão em pausa prolongada.
</p>
</div>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="archiveDialogOpen = false" />
<Button label="Compreendo, arquivar mesmo assim!" icon="pi pi-archive" @click="confirmArchive" />
</div>
</template>
</Dialog>
<!-- Dialog: Desativar -->
<Dialog v-model:visible="deactivateDialogOpen" modal :draggable="false" header="Desativar paciente" :style="{ width: '460px', maxWidth: '95vw' }">
<div class="flex gap-3 items-start py-2">
<i class="pi pi-exclamation-triangle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
<p class="m-0 font-semibold">Atenção: este paciente pode possuir sessões agendadas e/ou recorrências ativas.</p>
<p class="m-0">Ao desativar, todas as sessões e recorrências serão mantidas na agenda porém novos agendamentos ficarão bloqueados.</p>
<p class="m-0 text-[var(--text-color-secondary)]">Recomendamos revisar a agenda e encerrar as recorrências manualmente antes de prosseguir.</p>
</div>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="deactivateDialogOpen = false" />
<Button label="Entendo, desativar mesmo assim!" icon="pi pi-pause" severity="warn" @click="confirmDeactivate" />
</div>
</template>
</Dialog>
<!-- Dialog: Exclusão bloqueada (paciente com histórico) -->
<Dialog
v-model:visible="hasHistoryDialogOpen"
modal
:draggable="false"
header="Exclusão não permitida"
:style="{ width: '420px', maxWidth: '95vw' }"
>
<div class="flex gap-3 items-start py-2">
<i class="pi pi-info-circle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
<p class="text-[var(--text-color)] text-sm leading-relaxed m-0">
Este paciente possui histórico clínico e <strong>não pode ser removido permanentemente</strong>.
Apenas o arquivamento é permitido para pacientes com registros de atendimento.
</p>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="hasHistoryDialogOpen = false" />
<Button label="Arquivar paciente" icon="pi pi-archive" @click="confirmArchive" />
</div>
</template>
</Dialog>
<!-- Dialog: Arquivar -->
<Dialog v-model:visible="archiveDialogOpen" modal :draggable="false" header="Arquivar paciente" :style="{ width: '460px', maxWidth: '95vw' }">
<div class="flex gap-3 items-start py-2">
<i class="pi pi-archive text-slate-500 text-2xl mt-0.5 flex-shrink-0" />
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
<p class="m-0 font-semibold">O que acontece ao arquivar um paciente?</p>
<ul class="m-0 pl-4 space-y-1 text-[var(--text-color)]">
<li>O paciente sairá da listagem ativa e ficará oculto nas buscas padrão.</li>
<li>Todo o histórico clínico, sessões e registros financeiros serão preservados.</li>
<li>Novos agendamentos para este paciente ficarão bloqueados.</li>
<li>O paciente pode ser <strong>reativado</strong> a qualquer momento pelo menu de ações.</li>
</ul>
<p class="m-0 text-[var(--text-color-secondary)]">Arquivar é indicado para pacientes que concluíram o acompanhamento ou que estão em pausa prolongada.</p>
</div>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="archiveDialogOpen = false" />
<Button label="Compreendo, arquivar mesmo assim!" icon="pi pi-archive" @click="confirmArchive" />
</div>
</template>
</Dialog>
<!-- Dialog: Exclusão bloqueada (paciente com histórico) -->
<Dialog v-model:visible="hasHistoryDialogOpen" modal :draggable="false" header="Exclusão não permitida" :style="{ width: '420px', maxWidth: '95vw' }">
<div class="flex gap-3 items-start py-2">
<i class="pi pi-info-circle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
<p class="text-[var(--text-color)] text-sm leading-relaxed m-0">
Este paciente possui histórico clínico e <strong>não pode ser removido permanentemente</strong>. Apenas o arquivamento é permitido para pacientes com registros de atendimento.
</p>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="hasHistoryDialogOpen = false" />
<Button label="Arquivar paciente" icon="pi pi-archive" @click="confirmArchive" />
</div>
</template>
</Dialog>
</template>
+20 -20
View File
@@ -15,33 +15,33 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { computed } from 'vue';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
const props = defineProps({
feature: {
type: String,
required: true
},
fallback: {
type: Boolean,
default: false
}
})
feature: {
type: String,
required: true
},
fallback: {
type: Boolean,
default: false
}
});
const ent = useEntitlementsStore()
const ent = useEntitlementsStore();
const allowed = computed(() => {
return ent.can(props.feature)
})
return ent.can(props.feature);
});
</script>
<template>
<template v-if="allowed">
<slot />
</template>
<template v-if="allowed">
<slot />
</template>
<template v-else-if="fallback">
<slot name="fallback" />
</template>
<template v-else-if="fallback">
<slot name="fallback" />
</template>
</template>
+61 -51
View File
@@ -14,74 +14,84 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Transition name="fade-up" appear>
<div class="flex flex-col items-center justify-center gap-6" :class="containerClass">
<!-- Motivação -->
<div class="flex flex-col items-center gap-2 text-center px-6">
<span class="text-base font-semibold text-[var(--text-color,#1e293b)] leading-snug max-w-[320px]">
{{ motivation || '...' }}
</span>
<span class="text-sm text-[var(--text-color-secondary,#64748b)]">
{{ action }}
</span>
</div>
<!-- Progress bar -->
<div class="w-[220px] h-[3px] rounded-full bg-[var(--surface-border,#e2e8f0)] overflow-hidden">
<div class="progress-bar h-full rounded-full bg-[var(--primary-color,#6366f1)]" />
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted } from 'vue';
defineProps({
action: { type: String, default: 'Carregando...' },
containerClass: { type: String, default: 'py-24' },
})
action: { type: String, default: 'Carregando...' },
containerClass: { type: String, default: 'py-24' }
});
const motivation = ref(null)
const motivation = ref(null);
onMounted(async () => {
try {
const res = await fetch('/loading-phrases.json')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
const list = json.motivations || []
motivation.value = list.length
? list[Math.floor(Math.random() * list.length)]
: 'Carregando...'
} catch (e) {
console.warn('[AppLoadingPhrases] fetch falhou:', e)
motivation.value = 'Carregando...'
}
})
try {
const res = await fetch('/loading-phrases.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const list = json.motivations || [];
motivation.value = list.length ? list[Math.floor(Math.random() * list.length)] : 'Carregando...';
} catch (e) {
console.warn('[AppLoadingPhrases] fetch falhou:', e);
motivation.value = 'Carregando...';
}
});
</script>
<template>
<Transition name="fade-up" appear>
<div class="flex flex-col items-center justify-center gap-6" :class="containerClass">
<!-- Motivação -->
<div class="flex flex-col items-center gap-2 text-center px-6">
<span class="text-base font-semibold text-[var(--text-color,#1e293b)] leading-snug max-w-[320px]">
{{ motivation || '...' }}
</span>
<span class="text-sm text-[var(--text-color-secondary,#64748b)]">
{{ action }}
</span>
</div>
<!-- Progress bar -->
<div class="w-[220px] h-[3px] rounded-full bg-[var(--surface-border,#e2e8f0)] overflow-hidden">
<div class="progress-bar h-full rounded-full bg-[var(--primary-color,#6366f1)]" />
</div>
</div>
</Transition>
</template>
<style scoped>
/* Entrada do componente inteiro */
.fade-up-enter-active {
transition: opacity 0.45s ease, transform 0.45s ease;
transition:
opacity 0.45s ease,
transform 0.45s ease;
}
.fade-up-enter-from {
opacity: 0;
transform: translateY(16px);
opacity: 0;
transform: translateY(16px);
}
/* Progress bar — vai de 0% a 85% em ~2.5s, para não "completar" antes do loading acabar */
.progress-bar {
animation: progress-indeterminate 1.6s ease-in-out infinite;
transform-origin: left;
animation: progress-indeterminate 1.6s ease-in-out infinite;
transform-origin: left;
}
@keyframes progress-indeterminate {
0% { margin-left: 0%; width: 0%; }
30% { margin-left: 0%; width: 60%; }
70% { margin-left: 40%; width: 60%; }
100% { margin-left: 100%; width: 0%; }
0% {
margin-left: 0%;
width: 0%;
}
30% {
margin-left: 0%;
width: 60%;
}
70% {
margin-left: 40%;
width: 60%;
}
100% {
margin-left: 100%;
width: 0%;
}
}
</style>
</style>
+23 -23
View File
@@ -14,31 +14,31 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Transition name="loaded-phrase-in" appear>
<div v-if="phrase" class="loaded-phrase-block">
<div class="loaded-phrase-block__header">
<i class="pi pi-check-circle loaded-phrase-block__icon" />
<span class="loaded-phrase-block__title">Ambiente carregado!</span>
</div>
<p class="loaded-phrase-block__text">{{ phrase }}</p>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted } from 'vue';
const phrase = ref(null)
const phrase = ref(null);
onMounted(async () => {
try {
const res = await fetch('/loading-phrases.json')
const json = await res.json()
const list = json.motivations || []
phrase.value = list.length ? list[Math.floor(Math.random() * list.length)] : null
} catch {
phrase.value = null
}
})
try {
const res = await fetch('/loading-phrases.json');
const json = await res.json();
const list = json.motivations || [];
phrase.value = list.length ? list[Math.floor(Math.random() * list.length)] : null;
} catch {
phrase.value = null;
}
});
</script>
<template>
<Transition name="loaded-phrase-in" appear>
<div v-if="phrase" class="loaded-phrase-block">
<div class="loaded-phrase-block__header">
<i class="pi pi-check-circle loaded-phrase-block__icon" />
<span class="loaded-phrase-block__title">Ambiente carregado!</span>
</div>
<p class="loaded-phrase-block__text">{{ phrase }}</p>
</div>
</Transition>
</template>
+125 -151
View File
@@ -26,172 +26,146 @@
| created paciente criado ou atualizado com sucesso
|--------------------------------------------------------------------------
-->
<template>
<Dialog
v-model:visible="isOpen"
modal
:draggable="false"
:closable="false"
:dismissableMask="false"
:maximizable="false"
:style="{ width: '90vw', maxWidth: '1100px', height: maximized ? '100vh' : '90vh' }"
:contentStyle="{ padding: 0, overflow: 'auto', height: '100%' }"
pt:mask:class="backdrop-blur-xs"
>
<!-- Header -->
<template #header>
<div class="flex items-center justify-between w-full gap-3">
<!-- Título -->
<span class="text-base font-semibold text-[var(--text-color)] leading-tight">
{{ patientId ? 'Editar Paciente' : 'Cadastro de Paciente' }}
</span>
<!-- Botões à direita -->
<div class="flex items-center gap-1 ml-auto">
<!-- Preencher tudo ( testMODE) -->
<Button
v-if="pageRef?.canSee?.('testMODE')"
label="Preencher tudo"
icon="pi pi-bolt"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
@click="pageRef?.fillRandomPatient?.()"
/>
<!-- Excluir ( em edição) -->
<Button
v-if="patientId"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full"
:loading="pageRef?.deleting?.value"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
title="Excluir paciente"
@click="pageRef?.confirmDelete?.()"
/>
<!-- Maximizar -->
<button
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:title="maximized ? 'Restaurar' : 'Maximizar'"
@click="maximized = !maximized"
>
<i :class="maximized ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" />
</button>
<!-- Fechar -->
<button
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
title="Fechar"
@click="isOpen = false"
>
<i class="pi pi-times" />
</button>
</div>
</div>
</template>
<!-- Conteúdo -->
<PatientsCadastroPage
ref="pageRef"
:dialog-mode="true"
:patient-id="patientId"
@cancel="isOpen = false"
@created="onCreated"
/>
<!-- Footer -->
<template #footer>
<div class="flex justify-end gap-2">
<Button
label="Cancelar"
severity="secondary"
text
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="isOpen = false"
/>
<!-- Na rota de pacientes: "Salvar" -->
<Button
v-if="isOnPatientsPage"
label="Salvar"
:loading="!!pageRef?.saving?.value"
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="submitWith('only')"
/>
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
<template v-else>
<Button
label="Salvar e fechar"
severity="secondary"
outlined
:loading="pendingMode === 'only' && !!pageRef?.saving?.value"
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="submitWith('only')"
/>
<Button
label="Salvar e ver pacientes"
:loading="pendingMode === 'view' && !!pageRef?.saving?.value"
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="submitWith('view')"
/>
</template>
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PatientsCadastroPage from '@/features/patients/cadastro/PatientsCadastroPage.vue'
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PatientsCadastroPage from '@/features/patients/cadastro/PatientsCadastroPage.vue';
const props = defineProps({
modelValue: { type: Boolean, default: false },
patientId: { type: String, default: null }
})
const emit = defineEmits(['update:modelValue', 'created'])
modelValue: { type: Boolean, default: false },
patientId: { type: String, default: null }
});
const emit = defineEmits(['update:modelValue', 'created']);
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
});
// Reset maximized when dialog opens
watch(() => props.modelValue, (v) => { if (!v) maximized.value = false })
watch(
() => props.modelValue,
(v) => {
if (!v) maximized.value = false;
}
);
const maximized = ref(false)
const pageRef = ref(null)
const pendingMode = ref('only')
const maximized = ref(false);
const pageRef = ref(null);
const pendingMode = ref('only');
const route = useRoute()
const router = useRouter()
const route = useRoute();
const router = useRouter();
const isOnPatientsPage = computed(() => {
const p = String(route.path || '')
return p.includes('/patients') || p.includes('/pacientes')
})
const p = String(route.path || '');
return p.includes('/patients') || p.includes('/pacientes');
});
function patientsListRoute () {
const p = String(route.path || '')
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes'
function patientsListRoute() {
const p = String(route.path || '');
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
function submitWith (mode) {
pendingMode.value = mode
pageRef.value?.onSubmit()
function submitWith(mode) {
pendingMode.value = mode;
pageRef.value?.onSubmit();
}
async function onCreated (data) {
isOpen.value = false
emit('created', data)
if (pendingMode.value === 'view') {
await router.push(patientsListRoute())
}
async function onCreated(data) {
isOpen.value = false;
emit('created', data);
if (pendingMode.value === 'view') {
await router.push(patientsListRoute());
}
}
</script>
<template>
<Dialog
v-model:visible="isOpen"
modal
:draggable="false"
:closable="false"
:dismissableMask="false"
:maximizable="false"
:style="{ width: '90vw', maxWidth: '1100px', height: maximized ? '100vh' : '90vh' }"
:contentStyle="{ padding: 0, overflow: 'auto', height: '100%' }"
pt:mask:class="backdrop-blur-xs"
>
<!-- Header -->
<template #header>
<div class="flex items-center justify-between w-full gap-3">
<!-- Título -->
<span class="text-base font-semibold text-[var(--text-color)] leading-tight">
{{ patientId ? 'Editar Paciente' : 'Cadastro de Paciente' }}
</span>
<!-- Botões à direita -->
<div class="flex items-center gap-1 ml-auto">
<!-- Preencher tudo ( testMODE) -->
<Button
v-if="pageRef?.canSee?.('testMODE')"
label="Preencher tudo"
icon="pi pi-bolt"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
@click="pageRef?.fillRandomPatient?.()"
/>
<!-- Excluir ( em edição) -->
<Button
v-if="patientId"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full"
:loading="pageRef?.deleting?.value"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
title="Excluir paciente"
@click="pageRef?.confirmDelete?.()"
/>
<!-- Maximizar -->
<button
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:title="maximized ? 'Restaurar' : 'Maximizar'"
@click="maximized = !maximized"
>
<i :class="maximized ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" />
</button>
<!-- Fechar -->
<button
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
title="Fechar"
@click="isOpen = false"
>
<i class="pi pi-times" />
</button>
</div>
</div>
</template>
<!-- Conteúdo -->
<PatientsCadastroPage ref="pageRef" :dialog-mode="true" :patient-id="patientId" @cancel="isOpen = false" @created="onCreated" />
<!-- Footer -->
<template #footer>
<div class="flex justify-end gap-2">
<Button label="Cancelar" severity="secondary" text :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="isOpen = false" />
<!-- Na rota de pacientes: "Salvar" -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
</template>
</div>
</template>
</Dialog>
</template>
+131 -160
View File
@@ -26,183 +26,154 @@
| toggle(event) abre/fecha o Popover
|--------------------------------------------------------------------------
-->
<template>
<PatientCadastroDialog v-model="showCadastroDialog" />
<Popover ref="popRef">
<div class="flex flex-col min-w-[230px]">
<!-- Cadastro rápido -->
<button
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
@click="onQuickCreate"
>
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-bolt text-sm" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
</div>
</button>
<!-- Cadastro completo -->
<button
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
@click="onGoComplete"
>
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-user-plus text-sm" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
</div>
</button>
<!-- Divisor -->
<div class="mx-3 my-1.5 border-t border-[var(--surface-border,#e2e8f0)]" />
<!-- Link de cadastro -->
<div class="px-3 pb-3">
<div class="flex items-center gap-1.5 text-[0.68rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 mb-2">
<i class="pi pi-link text-[0.6rem]" />
Link de cadastro
</div>
<!-- Carregando token -->
<div v-if="loadingToken" class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] py-1">
<i class="pi pi-spin pi-spinner text-[0.7rem]" /> Carregando link
</div>
<!-- Sem token ainda -->
<div v-else-if="!inviteToken" class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-60 py-1">
Nenhum link ativo.
<button class="underline cursor-pointer border-0 bg-transparent text-[var(--primary-color,#6366f1)]" @click="loadToken">Tentar novamente</button>
</div>
<!-- URL + ações -->
<template v-else>
<InputGroup class="w-full">
<InputText
:value="publicUrl"
readonly
class="text-[0.68rem] font-mono"
style="min-width: 0"
/>
<InputGroupAddon
class="cursor-pointer hover:bg-[var(--surface-hover)] transition-colors"
title="Copiar link"
@click="copyLink"
>
<i class="pi pi-copy text-sm" />
</InputGroupAddon>
</InputGroup>
<div class="flex gap-1 mt-2">
<Button
label="Copiar mensagem"
icon="pi pi-comment"
text
size="small"
class="flex-1 text-xs rounded-full"
@click="copyMessage"
/>
<Button
icon="pi pi-external-link"
text
size="small"
class="rounded-full"
v-tooltip.top="'Abrir no navegador'"
@click="openLink"
/>
</div>
</template>
</div>
</div>
</Popover>
</template>
<script setup>
import { ref, computed } from 'vue'
import Popover from 'primevue/popover'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import PatientCadastroDialog from './PatientCadastroDialog.vue'
import { ref, computed } from 'vue';
import Popover from 'primevue/popover';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import PatientCadastroDialog from './PatientCadastroDialog.vue';
const emit = defineEmits(['quick-create'])
const showCadastroDialog = ref(false)
const toast = useToast()
const emit = defineEmits(['quick-create']);
const showCadastroDialog = ref(false);
const toast = useToast();
const popRef = ref(null)
const inviteToken = ref('')
const loadingToken = ref(false)
let tokenLoaded = false
const popRef = ref(null);
const inviteToken = ref('');
const loadingToken = ref(false);
let tokenLoaded = false;
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${window.location.origin}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
})
if (!inviteToken.value) return '';
return `${window.location.origin}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`;
});
async function loadToken () {
if (tokenLoaded || loadingToken.value) return
loadingToken.value = true
try {
const { data: authData } = await supabase.auth.getUser()
const uid = authData?.user?.id
if (!uid) return
const { data } = await supabase
.from('patient_invites')
.select('token')
.eq('owner_id', uid)
.eq('active', true)
.order('created_at', { ascending: false })
.limit(1)
if (data?.[0]?.token) {
inviteToken.value = data[0].token
tokenLoaded = true
async function loadToken() {
if (tokenLoaded || loadingToken.value) return;
loadingToken.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const uid = authData?.user?.id;
if (!uid) return;
const { data } = await supabase.from('patient_invites').select('token').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
if (data?.[0]?.token) {
inviteToken.value = data[0].token;
tokenLoaded = true;
}
} catch {
/* silencioso */
} finally {
loadingToken.value = false;
}
} catch { /* silencioso */ } finally {
loadingToken.value = false
}
}
function toggle (event) {
popRef.value?.toggle(event)
loadToken()
function toggle(event) {
popRef.value?.toggle(event);
loadToken();
}
function close () {
try { popRef.value?.hide() } catch {}
function close() {
try {
popRef.value?.hide();
} catch {}
}
function onQuickCreate () { close(); emit('quick-create') }
function onGoComplete () { close(); showCadastroDialog.value = true }
async function copyLink () {
if (!publicUrl.value) return
try {
await navigator.clipboard.writeText(publicUrl.value)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 })
} catch {
window.prompt('Copie o link:', publicUrl.value)
}
function onQuickCreate() {
close();
emit('quick-create');
}
function onGoComplete() {
close();
showCadastroDialog.value = true;
}
async function copyMessage () {
if (!publicUrl.value) return
try {
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
await navigator.clipboard.writeText(msg)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 })
} catch {}
async function copyLink() {
if (!publicUrl.value) return;
try {
await navigator.clipboard.writeText(publicUrl.value);
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 });
} catch {
window.prompt('Copie o link:', publicUrl.value);
}
}
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
async function copyMessage() {
if (!publicUrl.value) return;
try {
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`;
await navigator.clipboard.writeText(msg);
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 });
} catch {}
}
defineExpose({ toggle, close })
function openLink() {
if (!publicUrl.value) return;
window.open(publicUrl.value, '_blank', 'noopener');
}
defineExpose({ toggle, close });
</script>
<template>
<PatientCadastroDialog v-model="showCadastroDialog" />
<Popover ref="popRef">
<div class="flex flex-col min-w-[230px]">
<!-- Cadastro rápido -->
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onQuickCreate">
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-bolt text-sm" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
</div>
</button>
<!-- Cadastro completo -->
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onGoComplete">
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-user-plus text-sm" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
</div>
</button>
<!-- Divisor -->
<div class="mx-3 my-1.5 border-t border-[var(--surface-border,#e2e8f0)]" />
<!-- Link de cadastro -->
<div class="px-3 pb-3">
<div class="flex items-center gap-1.5 text-[0.68rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 mb-2">
<i class="pi pi-link text-[0.6rem]" />
Link de cadastro
</div>
<!-- Carregando token -->
<div v-if="loadingToken" class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] py-1"><i class="pi pi-spin pi-spinner text-[0.7rem]" /> Carregando link</div>
<!-- Sem token ainda -->
<div v-else-if="!inviteToken" class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-60 py-1">
Nenhum link ativo.
<button class="underline cursor-pointer border-0 bg-transparent text-[var(--primary-color,#6366f1)]" @click="loadToken">Tentar novamente</button>
</div>
<!-- URL + ações -->
<template v-else>
<InputGroup class="w-full">
<InputText :value="publicUrl" readonly class="text-[0.68rem] font-mono" style="min-width: 0" />
<InputGroupAddon class="cursor-pointer hover:bg-[var(--surface-hover)] transition-colors" title="Copiar link" @click="copyLink">
<i class="pi pi-copy text-sm" />
</InputGroupAddon>
</InputGroup>
<div class="flex gap-1 mt-2">
<Button label="Copiar mensagem" icon="pi pi-comment" text size="small" class="flex-1 text-xs rounded-full" @click="copyMessage" />
<Button icon="pi pi-external-link" text size="small" class="rounded-full" v-tooltip.top="'Abrir no navegador'" @click="openLink" />
</div>
</template>
</div>
</div>
</Popover>
</template>