Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização
This commit is contained in:
+27
-9
@@ -33,6 +33,10 @@ function isTenantArea(path = '') {
|
||||
}
|
||||
|
||||
// ── Setup Wizard redirect ────────────────────────────────────────
|
||||
// Cache por sessão: uma vez confirmado, não verifica de novo
|
||||
let _setupClearedUid = null;
|
||||
let _setupClearedIsClinic = null;
|
||||
|
||||
async function checkSetupWizard() {
|
||||
if (!isTenantArea(route.path)) return;
|
||||
if (route.path.includes('/setup')) return;
|
||||
@@ -40,19 +44,33 @@ async function checkSetupWizard() {
|
||||
const uid = tenantStore.user?.id;
|
||||
if (!uid) return;
|
||||
|
||||
const { data } = await supabase.from('agenda_configuracoes').select('setup_concluido, setup_clinica_concluido').eq('owner_id', uid).maybeSingle();
|
||||
|
||||
if (!data) return;
|
||||
|
||||
const activeMembership = tenantStore.memberships?.find((m) => m.id === tenantStore.activeTenantId);
|
||||
const activeMembership = tenantStore.memberships?.find((m) => m.tenant_id === tenantStore.activeTenantId);
|
||||
const kind = activeMembership?.kind ?? tenantStore.activeRole ?? '';
|
||||
const isClinic = kind.startsWith('clinic');
|
||||
|
||||
const setupDone = isClinic ? data.setup_clinica_concluido : data.setup_concluido;
|
||||
if (!setupDone) {
|
||||
const dest = isClinic ? '/admin/setup' : '/therapist/setup';
|
||||
router.push(dest);
|
||||
// Se já confirmamos que este uid passou o setup, não verifica de novo
|
||||
if (_setupClearedUid === uid && _setupClearedIsClinic === isClinic) return;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('agenda_configuracoes')
|
||||
.select('setup_concluido, setup_clinica_concluido, atendimento_mode')
|
||||
.eq('owner_id', uid)
|
||||
.maybeSingle();
|
||||
|
||||
if (!data) return; // sem linha = setup nunca iniciado, não redireciona
|
||||
|
||||
// Considera completo se qualquer flag de conclusão estiver setada
|
||||
const setupDone = data.setup_concluido || data.setup_clinica_concluido || !!data.atendimento_mode;
|
||||
|
||||
if (setupDone) {
|
||||
// Grava cache: não verifica mais nesta sessão
|
||||
_setupClearedUid = uid;
|
||||
_setupClearedIsClinic = isClinic;
|
||||
return;
|
||||
}
|
||||
|
||||
const dest = isClinic ? '/admin/setup' : '/therapist/setup';
|
||||
router.push(dest);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -273,3 +273,23 @@
|
||||
.app-dark .p-datatable tr.row-new-highlight td {
|
||||
background-color: color-mix(in srgb, var(--primary-color) 20%, transparent) !important;
|
||||
}
|
||||
|
||||
/* ── Agenda Preview ────────────────────────── */
|
||||
.app-dark .fc-scrollgrid-section-sticky > * {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.app-dark .fc-theme-standard td,
|
||||
.app-dark .fc-theme-standard th {
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.app-dark .fc-theme-standard .fc-scrollgrid {
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.app-dark .fc-timegrid-event-harness-inset .fc-timegrid-event,
|
||||
.app-dark .fc-timegrid-event.fc-event-mirror,
|
||||
.fc-timegrid-more-link {
|
||||
box-shadow: 0 0 0 1px #000000;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — CadastroRapidoConvenio.vue
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Componente de seleção e cadastro rápido de convênios.
|
||||
| Usado dentro do PatientsCadastroPage na seção "Clínico & origem".
|
||||
|
|
||||
| Props:
|
||||
| modelValue (String|null) — id do insurance_plan selecionado
|
||||
| visible (Boolean) — controla visibilidade do dialog
|
||||
|
|
||||
| Emits:
|
||||
| update:modelValue — string id selecionado
|
||||
| update:visible — fecha o dialog
|
||||
| selected — { id, name, notes, default_value } do plano escolhido
|
||||
|
|
||||
| Tabela: public.insurance_plans
|
||||
| id uuid, owner_id uuid, tenant_id uuid,
|
||||
| name text, notes text, default_value numeric, active boolean
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: null }, // id selecionado
|
||||
visible: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'update:visible', 'selected'])
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Auth / tenant helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function getOwnerId () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
return uid
|
||||
}
|
||||
async function getTenantId () {
|
||||
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
if (tid) return tid
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members').select('tenant_id')
|
||||
.eq('user_id', ownerId).eq('status', 'active')
|
||||
.order('created_at', { ascending: false }).limit(1).single()
|
||||
if (error) throw error
|
||||
return data?.tenant_id
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Estado
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const plans = ref([])
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
// Form de criação
|
||||
const showForm = ref(false)
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
const newPlan = ref({ name: '', notes: '', default_value: '' })
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Computed
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const filteredPlans = computed(() => {
|
||||
const q = searchTerm.value.toLowerCase().trim()
|
||||
if (!q) return plans.value
|
||||
return plans.value.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.notes||'').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPlan = computed(() =>
|
||||
plans.value.find(p => p.id === props.modelValue) || null
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Load
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function loadPlans () {
|
||||
loading.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('insurance_plans')
|
||||
.select('id, name, notes, default_value, active')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('active', true)
|
||||
.order('name', { ascending: true })
|
||||
if (error) throw error
|
||||
plans.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar convênios.', life: 3500 })
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) { loadPlans(); showForm.value = false; searchTerm.value = ''; formErr.value = '' }
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Selecionar
|
||||
// ─────────────────────────────────────────────────────────
|
||||
function selectPlan (plan) {
|
||||
emit('update:modelValue', plan.id)
|
||||
emit('selected', plan)
|
||||
close()
|
||||
}
|
||||
function clearSelection () {
|
||||
emit('update:modelValue', null)
|
||||
emit('selected', null)
|
||||
close()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Criar
|
||||
// ─────────────────────────────────────────────────────────
|
||||
function openForm () {
|
||||
formErr.value = ''
|
||||
newPlan.value = { name: '', notes: '', default_value: '' }
|
||||
showForm.value = true
|
||||
}
|
||||
function cancelForm () {
|
||||
showForm.value = false
|
||||
formErr.value = ''
|
||||
}
|
||||
async function savePlan () {
|
||||
const name = String(newPlan.value.name || '').trim()
|
||||
if (!name) { formErr.value = 'Informe o nome do convênio.'; return }
|
||||
saving.value = true; formErr.value = ''
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const tenantId = await getTenantId()
|
||||
const payload = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
name,
|
||||
notes: String(newPlan.value.notes || '').trim() || null,
|
||||
default_value: newPlan.value.default_value !== '' ? Number(newPlan.value.default_value) : null,
|
||||
active: true,
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('insurance_plans').insert(payload)
|
||||
.select('id, name, notes, default_value, active').single()
|
||||
if (error) throw error
|
||||
plans.value = [...plans.value, data].sort((a, b) => a.name.localeCompare(b.name))
|
||||
toast.add({ severity: 'success', summary: 'Convênio criado', detail: `"${data.name}" adicionado.`, life: 2500 })
|
||||
selectPlan(data)
|
||||
} catch (e) {
|
||||
const msg = e?.message || ''
|
||||
formErr.value = /duplicate/i.test(msg) ? 'Já existe um convênio com esse nome.' : (msg || 'Falha ao criar.')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
function close () { emit('update:visible', false) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
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"
|
||||
>
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-blue-100 text-blue-600 text-[0.8rem] shrink-0">
|
||||
<i class="pi pi-shield"/>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">Convênios</div>
|
||||
<div class="text-xs opacity-50">Selecione ou cadastre um novo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-0">
|
||||
|
||||
<!-- Selecionado atualmente -->
|
||||
<div v-if="selectedPlan && !showForm" class="flex items-center gap-2 mb-3 p-2.5 rounded-lg bg-blue-50 border border-blue-200/60">
|
||||
<i class="pi pi-check-circle text-blue-500 shrink-0"/>
|
||||
<span class="text-[0.82rem] font-semibold text-blue-700 flex-1 truncate">{{ selectedPlan.name }}</span>
|
||||
<button type="button" class="text-[0.7rem] text-blue-400 hover:text-blue-600 underline" @click="clearSelection">remover</button>
|
||||
</div>
|
||||
|
||||
<!-- Form de criação inline -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="showForm" class="mb-4 p-3.5 rounded-xl border border-blue-200/60 bg-blue-50/60">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest text-blue-500">Novo convênio</span>
|
||||
<div class="flex-1 h-px bg-blue-200/50"/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="cn_name" v-model="newPlan.name" class="w-full" variant="filled" autofocus @keydown.enter="savePlan"/>
|
||||
<label for="cn_name">Nome do convênio *</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.65rem] text-blue-500/80">Ex: Unimed, Amil, Bradesco Saúde.</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="cn_notes" v-model="newPlan.notes" class="w-full" variant="filled" @keydown.enter="savePlan"/>
|
||||
<label for="cn_notes">Observações (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-dollar"/>
|
||||
<InputNumber
|
||||
id="cn_value"
|
||||
v-model="newPlan.default_value"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:min="0"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
locale="pt-BR"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cn_value">Valor padrão da sessão (opcional)</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.65rem] text-blue-500/80">Pré-preenchido ao criar sessão com este convênio.</div>
|
||||
</div>
|
||||
<div v-if="formErr" class="text-[0.8rem] text-red-500 flex items-center gap-1.5">
|
||||
<i class="pi pi-exclamation-circle shrink-0"/>{{ formErr }}
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<Button label="Cancelar" severity="secondary" text class="flex-1 rounded-full hover:!text-red-500" @click="cancelForm"/>
|
||||
<Button label="Salvar convênio" icon="pi pi-check" class="flex-1 rounded-full" :loading="saving" @click="savePlan"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Busca -->
|
||||
<div v-if="!showForm" class="mb-3">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search"/>
|
||||
<InputText v-model="searchTerm" class="w-full" variant="filled" placeholder="Buscar convênio…"/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="!showForm" class="flex flex-col gap-1 max-h-[280px] overflow-y-auto pr-1">
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-8 gap-2 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner"/> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!filteredPlans.length" class="text-center py-8">
|
||||
<i class="pi pi-shield text-3xl text-[var(--text-color-secondary)] opacity-30 block mb-2"/>
|
||||
<div class="text-[0.82rem] text-[var(--text-color-secondary)]">
|
||||
{{ searchTerm ? 'Nenhum convênio encontrado.' : 'Nenhum convênio cadastrado ainda.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="plan in filteredPlans" :key="plan.id"
|
||||
type="button"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-left border transition-all duration-100 w-full group"
|
||||
:class="modelValue === plan.id
|
||||
? 'bg-blue-500/10 border-blue-300/50 text-blue-700'
|
||||
: 'border-transparent hover:bg-[var(--surface-ground)] text-[var(--text-color)]'"
|
||||
@click="selectPlan(plan)"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 text-[0.8rem] font-bold transition-colors"
|
||||
:class="modelValue === plan.id
|
||||
? 'bg-blue-200 text-blue-700'
|
||||
: 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)] group-hover:bg-blue-100 group-hover:text-blue-600'"
|
||||
>
|
||||
{{ plan.name.slice(0,2).toUpperCase() }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[0.88rem] font-semibold leading-tight truncate">{{ plan.name }}</div>
|
||||
<div v-if="plan.notes" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ plan.notes }}</div>
|
||||
</div>
|
||||
<div v-if="plan.default_value" class="text-[0.75rem] font-semibold text-emerald-600 shrink-0">
|
||||
R$ {{ Number(plan.default_value).toLocaleString('pt-BR', { minimumFractionDigits: 2 }) }}
|
||||
</div>
|
||||
<i v-if="modelValue === plan.id" class="pi pi-check text-blue-500 shrink-0"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Botão cadastrar novo -->
|
||||
<div v-if="!showForm && !loading" class="border-t border-[var(--surface-border)] mt-3 pt-3">
|
||||
<Button
|
||||
label="Cadastrar novo convênio"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full w-full"
|
||||
@click="openForm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-3">
|
||||
<Button
|
||||
label="Fechar"
|
||||
severity="secondary"
|
||||
text
|
||||
class="rounded-full hover:!text-red-500"
|
||||
:disabled="saving"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,646 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — CadastroRapidoMedico.vue
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Dialog de cadastro rápido de médicos / profissionais de referência.
|
||||
| Usado em PatientsCadastroPage (campo "Encaminhado por") e acessível
|
||||
| pela futura MedicosCadastroPage.
|
||||
|
|
||||
| Props:
|
||||
| visible (Boolean)
|
||||
|
|
||||
| Emits:
|
||||
| update:visible
|
||||
| created — objeto do médico recém-criado
|
||||
| selected — médico selecionado da lista (para preencher campo no form)
|
||||
|
|
||||
| Tabela: public.medicos (ver medicos.sql)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { digitsOnly, fmtPhone } from '@/utils/validators'
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
editId: { type: String, default: null }, // uuid do médico a editar (null = novo)
|
||||
})
|
||||
const emit = defineEmits(['update:visible', 'created', 'selected'])
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Auth / tenant
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function getOwnerId () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
return uid
|
||||
}
|
||||
async function getTenantId () {
|
||||
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
if (tid) return tid
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members').select('tenant_id')
|
||||
.eq('user_id', ownerId).eq('status', 'active')
|
||||
.order('created_at', { ascending: false }).limit(1).single()
|
||||
if (error) throw error
|
||||
return data?.tenant_id
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Views: 'list' | 'create' | 'edit'
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const view = ref('list')
|
||||
const medicos = ref([])
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const editingId = ref(null) // uuid do médico sendo editado
|
||||
|
||||
// Form
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
const showTelProfissional = ref(false)
|
||||
const showTelPessoal = ref(false)
|
||||
|
||||
function resetForm () {
|
||||
return {
|
||||
nome: '',
|
||||
crm: '',
|
||||
especialidade: '',
|
||||
especialidade_outra: '',
|
||||
telefone_profissional: '',
|
||||
telefone_pessoal: '',
|
||||
email: '',
|
||||
clinica: '',
|
||||
cidade: '',
|
||||
estado: 'SP',
|
||||
observacoes: '',
|
||||
}
|
||||
}
|
||||
const form = ref(resetForm())
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Especialidades
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const especialidadesOpts = [
|
||||
{ label: 'Psiquiatria', value: 'Psiquiatria' },
|
||||
{ label: 'Neurologia', value: 'Neurologia' },
|
||||
{ label: 'Neuropsiquiatria infantil', value: 'Neuropsiquiatria infantil' },
|
||||
{ label: 'Clínica geral', value: 'Clínica geral' },
|
||||
{ label: 'Pediatria', value: 'Pediatria' },
|
||||
{ label: 'Geriatria', value: 'Geriatria' },
|
||||
{ label: 'Endocrinologia', value: 'Endocrinologia' },
|
||||
{ label: 'Psicologia (encaminhador)', value: 'Psicologia (encaminhador)' },
|
||||
{ label: 'Assistência social', value: 'Assistência social' },
|
||||
{ label: 'Fonoaudiologia', value: 'Fonoaudiologia' },
|
||||
{ label: 'Terapia ocupacional', value: 'Terapia ocupacional' },
|
||||
{ label: 'Fisioterapia', value: 'Fisioterapia' },
|
||||
{ label: 'Outra', value: '__outra__' },
|
||||
]
|
||||
|
||||
const especialidadeFinal = computed(() =>
|
||||
form.value.especialidade === '__outra__'
|
||||
? (form.value.especialidade_outra.trim() || null)
|
||||
: (form.value.especialidade || null)
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Computed
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const filteredMedicos = computed(() => {
|
||||
const q = searchTerm.value.toLowerCase().trim()
|
||||
if (!q) return medicos.value
|
||||
return medicos.value.filter(m =>
|
||||
(m.nome || '').toLowerCase().includes(q) ||
|
||||
(m.especialidade || '').toLowerCase().includes(q) ||
|
||||
(m.crm || '').toLowerCase().includes(q) ||
|
||||
(m.clinica || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Load
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function loadMedicos () {
|
||||
loading.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('medicos')
|
||||
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('ativo', true)
|
||||
.order('nome', { ascending: true })
|
||||
if (error) throw error
|
||||
medicos.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médicos.', life: 3500 })
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
searchTerm.value = ''
|
||||
formErr.value = ''
|
||||
showTelProfissional.value = false
|
||||
showTelPessoal.value = false
|
||||
if (props.editId) {
|
||||
// Abre direto no form de edição com os dados carregados
|
||||
await loadMedicoForEdit(props.editId)
|
||||
} else {
|
||||
view.value = 'list'
|
||||
loadMedicos()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadMedicoForEdit (id) {
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('medicos').select('*').eq('id', id).eq('owner_id', ownerId).single()
|
||||
if (error) throw error
|
||||
form.value = {
|
||||
nome: data.nome || '',
|
||||
crm: data.crm || '',
|
||||
especialidade: data.especialidade || '',
|
||||
especialidade_outra: '',
|
||||
telefone_profissional: data.telefone_profissional ? fmtPhone(data.telefone_profissional) : '',
|
||||
telefone_pessoal: data.telefone_pessoal ? fmtPhone(data.telefone_pessoal) : '',
|
||||
email: data.email || '',
|
||||
clinica: data.clinica || '',
|
||||
cidade: data.cidade || '',
|
||||
estado: data.estado || 'SP',
|
||||
observacoes: data.observacoes || '',
|
||||
}
|
||||
editingId.value = id
|
||||
view.value = 'edit'
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médico.', life: 3000 })
|
||||
view.value = 'list'
|
||||
loadMedicos()
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Ações lista
|
||||
// ─────────────────────────────────────────────────────────
|
||||
function openCreate () {
|
||||
form.value = resetForm()
|
||||
formErr.value = ''
|
||||
editingId.value = null
|
||||
showTelProfissional.value = false
|
||||
showTelPessoal.value = false
|
||||
view.value = 'create'
|
||||
}
|
||||
function backToList () {
|
||||
view.value = 'list'
|
||||
formErr.value = ''
|
||||
editingId.value = null
|
||||
loadMedicos()
|
||||
}
|
||||
function selectMedico (m) {
|
||||
emit('selected', m)
|
||||
close()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Salvar
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function saveMedico () {
|
||||
const nome = String(form.value.nome || '').trim()
|
||||
if (!nome) { formErr.value = 'Informe o nome do médico.'; return }
|
||||
if (form.value.especialidade === '__outra__' && !form.value.especialidade_outra.trim()) {
|
||||
formErr.value = 'Informe a especialidade.'; return
|
||||
}
|
||||
|
||||
saving.value = true; formErr.value = ''
|
||||
const isUpdate = !!editingId.value
|
||||
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const tenantId = await getTenantId()
|
||||
|
||||
const payload = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome,
|
||||
crm: String(form.value.crm || '').trim() || null,
|
||||
especialidade: especialidadeFinal.value,
|
||||
telefone_profissional: form.value.telefone_profissional ? digitsOnly(form.value.telefone_profissional) : null,
|
||||
telefone_pessoal: form.value.telefone_pessoal ? digitsOnly(form.value.telefone_pessoal) : null,
|
||||
email: String(form.value.email || '').trim() || null,
|
||||
clinica: String(form.value.clinica || '').trim() || null,
|
||||
cidade: String(form.value.cidade || '').trim() || null,
|
||||
estado: String(form.value.estado || '').trim() || null,
|
||||
observacoes: String(form.value.observacoes || '').trim() || null,
|
||||
ativo: true,
|
||||
}
|
||||
|
||||
let data
|
||||
if (isUpdate) {
|
||||
const { data: d, error } = await supabase
|
||||
.from('medicos').update({ ...payload, updated_at: new Date().toISOString() })
|
||||
.eq('id', editingId.value).eq('owner_id', ownerId)
|
||||
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
|
||||
.single()
|
||||
if (error) throw error
|
||||
data = d
|
||||
toast.add({ severity: 'success', summary: 'Médico atualizado', detail: `Dr(a). ${data.nome} atualizado.`, life: 2500 })
|
||||
} else {
|
||||
const { data: d, error } = await supabase
|
||||
.from('medicos').insert(payload)
|
||||
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
|
||||
.single()
|
||||
if (error) throw error
|
||||
data = d
|
||||
toast.add({ severity: 'success', summary: 'Médico cadastrado', detail: `Dr(a). ${data.nome} adicionado.`, life: 2500 })
|
||||
}
|
||||
|
||||
emit(isUpdate ? 'selected' : 'created', data)
|
||||
emit('selected', data)
|
||||
close()
|
||||
} catch (e) {
|
||||
const msg = e?.message || ''
|
||||
if (e?.code === '23505' || /duplicate/i.test(msg)) {
|
||||
formErr.value = 'Já existe um cadastro com este CRM para este profissional.'
|
||||
} else {
|
||||
formErr.value = msg || 'Falha ao salvar.'
|
||||
}
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
function close () {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
maximizable
|
||||
class="dc-dialog w-[50rem]"
|
||||
: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"
|
||||
>
|
||||
<!-- ── Header ──────────────────────────────────────── -->
|
||||
<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">
|
||||
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-teal-100 text-teal-600 text-[0.8rem] shrink-0">
|
||||
<i class="pi pi-user-plus"/>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">Médicos & referências</div>
|
||||
<div class="text-xs opacity-50">
|
||||
<template v-if="view === 'list'">Selecione ou cadastre um novo profissional</template>
|
||||
<template v-else-if="editingId">Editar dados do médico</template>
|
||||
<template v-else>Novo médico / profissional de referência</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════
|
||||
VIEW: LISTA
|
||||
════════════════════════════════════════════════════ -->
|
||||
<div v-if="view === 'list'" class="flex flex-col -mt-1">
|
||||
|
||||
<!-- Busca -->
|
||||
<div class="mb-3">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search"/>
|
||||
<InputText v-model="searchTerm" class="w-full" variant="filled" placeholder="Buscar por nome, especialidade, CRM…"/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto pr-0.5">
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-8 gap-2 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner"/> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!filteredMedicos.length" class="flex flex-col items-center py-8 gap-2 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-teal-50 flex items-center justify-center">
|
||||
<i class="pi pi-user-plus text-xl text-teal-300"/>
|
||||
</div>
|
||||
<div class="text-[0.82rem] text-[var(--text-color-secondary)]">
|
||||
{{ searchTerm ? 'Nenhum médico encontrado.' : 'Nenhum médico cadastrado ainda.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="m in filteredMedicos" :key="m.id"
|
||||
type="button"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-left border border-transparent hover:bg-[var(--surface-ground)] hover:border-teal-100 transition-all duration-100 w-full group"
|
||||
@click="selectMedico(m)"
|
||||
>
|
||||
<!-- Iniciais -->
|
||||
<div class="w-9 h-9 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.75rem] text-teal-700 shrink-0 group-hover:bg-teal-200 transition-colors select-none">
|
||||
{{ (m.nome||'?').split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).slice(0,2).join('') }}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[0.88rem] font-semibold text-[var(--text-color)] truncate leading-tight">
|
||||
Dr(a). {{ m.nome }}
|
||||
</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">
|
||||
<template v-if="m.especialidade">{{ m.especialidade }}</template>
|
||||
<template v-if="m.crm"> · CRM {{ m.crm }}</template>
|
||||
<template v-if="m.clinica"> · {{ m.clinica }}</template>
|
||||
<template v-if="m.cidade"> · {{ m.cidade }}<template v-if="m.estado">/{{ m.estado }}</template></template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i class="pi pi-chevron-right text-[0.68rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-70 shrink-0"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--surface-border)] mt-3 pt-3">
|
||||
<Button
|
||||
label="Cadastrar novo médico"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full w-full"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════
|
||||
VIEW: CRIAR
|
||||
════════════════════════════════════════════════════ -->
|
||||
<div v-else class="flex flex-col gap-3.5 -mt-1">
|
||||
|
||||
<!-- Voltar -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 text-[0.77rem] text-[var(--text-color-secondary)] hover:text-teal-600 transition-colors w-fit"
|
||||
@click="backToList"
|
||||
>
|
||||
<i class="pi pi-arrow-left text-[0.72rem]"/> Voltar para a lista
|
||||
</button>
|
||||
|
||||
<!-- Nome + CRM -->
|
||||
<div class="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_150px]">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user"/>
|
||||
<InputText id="m_nome" v-model="form.nome" class="w-full" variant="filled" autofocus/>
|
||||
</IconField>
|
||||
<label for="m_nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="m_crm" v-model="form.crm" class="w-full" variant="filled"/>
|
||||
<label for="m_crm">CRM (ex: 123456/SP)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="m_esp"
|
||||
v-model="form.especialidade"
|
||||
:options="especialidadesOpts"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
filter
|
||||
filterPlaceholder="Buscar especialidade…"
|
||||
/>
|
||||
<label for="m_esp">Especialidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade "Outra" — aparece condicionalmente -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
leave-active-class="transition-all duration-100 ease-in"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div v-if="form.especialidade === '__outra__'">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
id="m_esp_outra"
|
||||
v-model="form.especialidade_outra"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
placeholder="Descreva a especialidade"
|
||||
/>
|
||||
<label for="m_esp_outra">Qual especialidade? *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Divider contatos -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Contatos</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50"/>
|
||||
</div>
|
||||
|
||||
<!-- Telefone profissional — máscara normal, olho aparece só quando preenchido -->
|
||||
<div>
|
||||
<div class="relative">
|
||||
<InputMask
|
||||
id="m_tel_prof"
|
||||
v-model="form.telefone_profissional"
|
||||
mask="(99) 99999-9999"
|
||||
:unmask="false"
|
||||
class="w-full"
|
||||
:class="form.telefone_profissional ? 'pr-10' : ''"
|
||||
variant="filled"
|
||||
placeholder="(00) 00000-0000"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Olho — só renderiza quando há dígitos preenchidos -->
|
||||
<button
|
||||
v-if="form.telefone_profissional?.replace(/\D/g,'').length >= 10"
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 transition-colors z-10"
|
||||
:class="showTelProfissional ? 'text-teal-600' : 'text-[var(--text-color-secondary)] hover:text-teal-600'"
|
||||
tabindex="-1"
|
||||
:title="showTelProfissional ? 'Ocultar número' : 'Revelar número completo'"
|
||||
@click="showTelProfissional = !showTelProfissional"
|
||||
>
|
||||
<i :class="showTelProfissional ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-[0.9rem]"/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Número revelado abaixo do campo -->
|
||||
<div
|
||||
v-if="showTelProfissional && form.telefone_profissional"
|
||||
class="mt-1.5 flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-teal-50 border border-teal-200/60"
|
||||
>
|
||||
<i class="pi pi-phone text-teal-500 text-[0.75rem] shrink-0"/>
|
||||
<span class="text-[0.82rem] font-semibold text-teal-700 tracking-wide">{{ form.telefone_profissional }}</span>
|
||||
</div>
|
||||
<div v-else class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
|
||||
Número do consultório ou clínica.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telefone pessoal — mesma lógica -->
|
||||
<div>
|
||||
<div class="relative">
|
||||
<InputMask
|
||||
id="m_tel_pes"
|
||||
v-model="form.telefone_pessoal"
|
||||
mask="(99) 99999-9999"
|
||||
:unmask="false"
|
||||
class="w-full"
|
||||
:class="form.telefone_pessoal ? 'pr-10' : ''"
|
||||
variant="filled"
|
||||
placeholder="(00) 00000-0000"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="form.telefone_pessoal?.replace(/\D/g,'').length >= 10"
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 transition-colors z-10"
|
||||
:class="showTelPessoal ? 'text-teal-600' : 'text-[var(--text-color-secondary)] hover:text-teal-600'"
|
||||
tabindex="-1"
|
||||
:title="showTelPessoal ? 'Ocultar número' : 'Revelar número completo'"
|
||||
@click="showTelPessoal = !showTelPessoal"
|
||||
>
|
||||
<i :class="showTelPessoal ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-[0.9rem]"/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showTelPessoal && form.telefone_pessoal"
|
||||
class="mt-1.5 flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-teal-50 border border-teal-200/60"
|
||||
>
|
||||
<i class="pi pi-mobile text-teal-500 text-[0.75rem] shrink-0"/>
|
||||
<span class="text-[0.82rem] font-semibold text-teal-700 tracking-wide">{{ form.telefone_pessoal }}</span>
|
||||
</div>
|
||||
<div v-else class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
|
||||
Pessoal / WhatsApp — toque no olho para revelar após digitar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-envelope"/>
|
||||
<InputText id="m_email" v-model="form.email" class="w-full" variant="filled"/>
|
||||
</IconField>
|
||||
<label for="m_email">E-mail profissional</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Divider localização -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Localização</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50"/>
|
||||
</div>
|
||||
|
||||
<!-- Clínica + Cidade + UF -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-building"/>
|
||||
<InputText id="m_clinica" v-model="form.clinica" class="w-full" variant="filled"/>
|
||||
</IconField>
|
||||
<label for="m_clinica">Clínica / Hospital</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr_90px] gap-3">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-map-marker"/>
|
||||
<InputText id="m_cidade" v-model="form.cidade" class="w-full" variant="filled"/>
|
||||
</IconField>
|
||||
<label for="m_cidade">Cidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="m_uf" v-model="form.estado" class="w-full" variant="filled"/>
|
||||
<label for="m_uf">UF</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea id="m_obs" v-model="form.observacoes" rows="2" class="w-full" variant="filled"/>
|
||||
<label for="m_obs">Observações internas</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
|
||||
Ex: aceita WhatsApp, convênios atendidos, melhor horário.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="formErr" class="flex items-start gap-1.5 text-[0.82rem] text-red-500 font-medium">
|
||||
<i class="pi pi-exclamation-circle mt-0.5 shrink-0"/> {{ formErr }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ──────────────────────────────────────── -->
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-3">
|
||||
<Button
|
||||
v-if="view !== 'list'"
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
text
|
||||
class="rounded-full hover:!text-red-500"
|
||||
:disabled="saving"
|
||||
@click="backToList"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
label="Fechar"
|
||||
severity="secondary"
|
||||
text
|
||||
class="rounded-full hover:!text-red-500"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
v-if="view !== 'list'"
|
||||
:label="editingId ? 'Salvar alterações' : 'Salvar médico'"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="saving"
|
||||
@click="saveMedico"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard';
|
||||
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
|
||||
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
@@ -130,18 +131,8 @@ function close() {
|
||||
|
||||
function onHide() {}
|
||||
|
||||
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 normalizePhoneDigits(v) {
|
||||
const digits = String(v || '').replace(/\D/g, '');
|
||||
return digits || null;
|
||||
return sanitizeDigits(v);
|
||||
}
|
||||
|
||||
async function getOwnerId() {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">SAKAI</h4>
|
||||
<h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">Agência PSI</h4>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ function smoothScroll(id) {
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">SAKAI</span>
|
||||
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">Agência PSI</span>
|
||||
</a>
|
||||
<Button
|
||||
class="lg:hidden!"
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/components/ui/JoditEmailEditor.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { Jodit } from 'jodit/esm/index.js';
|
||||
import 'jodit/es2021/jodit.min.css';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
minHeight: { type: Number, default: 150 },
|
||||
// true → toolbar enxuta + botões ▣ de layout para header/footer
|
||||
layoutButtons: { type: Boolean, default: false },
|
||||
// URL da logo do tenant usada nos snippets de layout
|
||||
logoUrl: { type: String, default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const container = ref(null);
|
||||
let jodit = null;
|
||||
let _ignoreChange = false;
|
||||
let _themeObserver = null;
|
||||
|
||||
// ── Dark mode ─────────────────────────────────────────────────
|
||||
function isDark() {
|
||||
return document.documentElement.classList.contains('app-dark');
|
||||
}
|
||||
|
||||
// ── Snippets de layout ────────────────────────────────────────
|
||||
function logoSnippet(url) {
|
||||
return url
|
||||
? `<img src="${url}" width="72" height="72" style="display:block;object-fit:contain;border-radius:4px;" alt="Logo" />`
|
||||
: `<div style="width:72px;height:72px;background:#e5e7eb;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:10px;color:#9ca3af;">[logo]</div>`;
|
||||
}
|
||||
|
||||
function snippetLogoLeft(logo) {
|
||||
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td width="88" valign="middle" style="padding-right:16px;">${logoSnippet(logo)}</td>
|
||||
<td valign="middle"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function snippetLogoRight(logo) {
|
||||
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td valign="middle" style="padding-right:16px;"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
|
||||
<td width="88" valign="middle" style="text-align:right;">${logoSnippet(logo)}</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function snippetLogoCenter(logo) {
|
||||
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:8px;">${logoSnippet(logo)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// ── Config Jodit ─────────────────────────────────────────────
|
||||
function buildConfig() {
|
||||
// Botões customizados de layout (somente nos editores de header/footer)
|
||||
const layoutExtraButtons = props.layoutButtons
|
||||
? [
|
||||
{
|
||||
name: 'layout-logo-left',
|
||||
tooltip: 'Logo à esquerda, texto à direita',
|
||||
text: '▣ Logo Esq.',
|
||||
exec(editor) {
|
||||
editor.selection.insertHTML(snippetLogoLeft(props.logoUrl));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'layout-logo-right',
|
||||
tooltip: 'Logo à direita, texto à esquerda',
|
||||
text: '▣ Logo Dir.',
|
||||
exec(editor) {
|
||||
editor.selection.insertHTML(snippetLogoRight(props.logoUrl));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'layout-logo-center',
|
||||
tooltip: 'Logo centralizada, texto abaixo',
|
||||
text: '▣ Logo Centro',
|
||||
exec(editor) {
|
||||
editor.selection.insertHTML(snippetLogoCenter(props.logoUrl));
|
||||
}
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
// Toolbar enxuta para header/footer — sem hr, eraser, source
|
||||
const layoutButtons = [
|
||||
'bold', 'italic', 'underline', '|',
|
||||
'font', 'fontsize', 'brush', '|',
|
||||
'align', '|',
|
||||
'link', '|',
|
||||
'layout-logo-left', 'layout-logo-right', 'layout-logo-center'
|
||||
];
|
||||
|
||||
// Toolbar completa para o corpo do e-mail
|
||||
const bodyButtons = [
|
||||
'bold', 'italic', 'underline', 'strikethrough', '|',
|
||||
'ul', 'ol', '|',
|
||||
'font', 'fontsize', 'brush', 'paragraph', '|',
|
||||
'align', '|',
|
||||
'link', 'table', '|',
|
||||
'hr', 'eraser', '|',
|
||||
'source'
|
||||
];
|
||||
|
||||
return {
|
||||
height: props.minHeight,
|
||||
language: 'pt_br',
|
||||
theme: isDark() ? 'dark' : 'default',
|
||||
toolbarAdaptive: false,
|
||||
toolbarSticky: false,
|
||||
showCharsCounter: false,
|
||||
showWordsCounter: false,
|
||||
showXPathInStatusbar: false,
|
||||
disablePlugins: ['about', 'stat'],
|
||||
buttons: props.layoutButtons ? layoutButtons : bodyButtons,
|
||||
extraButtons: layoutExtraButtons,
|
||||
uploader: { insertImageAsBase64URI: false },
|
||||
filebrowser: { ajax: { url: '' } }
|
||||
};
|
||||
}
|
||||
|
||||
// ── Init / destroy ────────────────────────────────────────────
|
||||
function initJodit() {
|
||||
if (jodit) {
|
||||
jodit.destruct();
|
||||
jodit = null;
|
||||
}
|
||||
jodit = Jodit.make(container.value, buildConfig());
|
||||
if (props.modelValue) jodit.value = props.modelValue;
|
||||
jodit.events.on('change', (content) => {
|
||||
if (!_ignoreChange) emit('update:modelValue', content);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
initJodit();
|
||||
|
||||
// Recria o editor se o tema mudar enquanto o componente estiver montado
|
||||
_themeObserver = new MutationObserver(() => {
|
||||
const current = isDark() ? 'dark' : 'default';
|
||||
if (jodit && jodit.o?.theme !== current) {
|
||||
const saved = jodit.value;
|
||||
initJodit();
|
||||
if (saved) jodit.value = saved;
|
||||
}
|
||||
});
|
||||
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
_themeObserver?.disconnect();
|
||||
_themeObserver = null;
|
||||
jodit?.destruct();
|
||||
jodit = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (!jodit) return;
|
||||
if (jodit.value !== (val ?? '')) {
|
||||
_ignoreChange = true;
|
||||
jodit.value = val ?? '';
|
||||
_ignoreChange = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── API exposta ───────────────────────────────────────────────
|
||||
defineExpose({
|
||||
insertHTML: (html) => jodit?.selection.insertHTML(html)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" />
|
||||
</template>
|
||||
@@ -31,10 +31,8 @@ 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 emit = defineEmits(['quick-create', 'go-complete', 'show', 'hide']);
|
||||
const toast = useToast();
|
||||
|
||||
const popRef = ref(null);
|
||||
@@ -83,7 +81,7 @@ function onQuickCreate() {
|
||||
}
|
||||
function onGoComplete() {
|
||||
close();
|
||||
showCadastroDialog.value = true;
|
||||
emit('go-complete');
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
@@ -114,9 +112,7 @@ defineExpose({ toggle, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PatientCadastroDialog v-model="showCadastroDialog" />
|
||||
|
||||
<Popover ref="popRef">
|
||||
<Popover ref="popRef" @show="emit('show')" @hide="emit('hide')">
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* useFormValidation — composable para validação de formulários com PrimeVue
|
||||
*
|
||||
* Retorna funções de validação prontas para usar em :invalid e mensagens de erro.
|
||||
*
|
||||
* Uso:
|
||||
* const { validateCPF, validatePhone, validateEmail, validateCEP } = useFormValidation()
|
||||
*
|
||||
* // No template:
|
||||
* <InputText v-model="cpf" :invalid="errors.cpf" @blur="errors.cpf = !validateCPF(cpf).valid" />
|
||||
* <small v-if="errors.cpf" class="p-error">{{ validateCPF(cpf).message }}</small>
|
||||
*/
|
||||
|
||||
import {
|
||||
isValidCPF,
|
||||
isValidCNPJ,
|
||||
isValidPhone,
|
||||
isValidEmail,
|
||||
isValidCEP,
|
||||
fmtCPF,
|
||||
fmtCNPJ,
|
||||
fmtPhone,
|
||||
fmtCEP,
|
||||
sanitizeDigits,
|
||||
toISODate,
|
||||
digitsOnly,
|
||||
} from '@/utils/validators'
|
||||
|
||||
export function useFormValidation() {
|
||||
|
||||
/** CPF — campo: `cpf` ou `cpf_responsavel` */
|
||||
function validateCPF(v, { required = false } = {}) {
|
||||
if (!v || digitsOnly(v).length === 0) {
|
||||
return required
|
||||
? { valid: false, message: 'CPF é obrigatório.' }
|
||||
: { valid: true, message: '' }
|
||||
}
|
||||
if (!isValidCPF(v)) return { valid: false, message: 'CPF inválido.' }
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
/** CNPJ */
|
||||
function validateCNPJ(v, { required = false } = {}) {
|
||||
if (!v || digitsOnly(v).length === 0) {
|
||||
return required
|
||||
? { valid: false, message: 'CNPJ é obrigatório.' }
|
||||
: { valid: true, message: '' }
|
||||
}
|
||||
if (!isValidCNPJ(v)) return { valid: false, message: 'CNPJ inválido.' }
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
/** Telefone — campos: `telefone`, `telefone_alternativo`, `telefone_parente`, `telefone_responsavel` */
|
||||
function validatePhone(v, { required = false } = {}) {
|
||||
if (!v || digitsOnly(v).length === 0) {
|
||||
return required
|
||||
? { valid: false, message: 'Telefone é obrigatório.' }
|
||||
: { valid: true, message: '' }
|
||||
}
|
||||
if (!isValidPhone(v)) return { valid: false, message: 'Telefone inválido. Use (XX) XXXXX-XXXX.' }
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
/** Email — campos: `email_principal`, `email_alternativo` */
|
||||
function validateEmail(v, { required = false } = {}) {
|
||||
if (!v || String(v).trim().length === 0) {
|
||||
return required
|
||||
? { valid: false, message: 'E-mail é obrigatório.' }
|
||||
: { valid: true, message: '' }
|
||||
}
|
||||
if (!isValidEmail(v)) return { valid: false, message: 'E-mail inválido.' }
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
/** CEP — campo: `cep` */
|
||||
function validateCEP(v, { required = false } = {}) {
|
||||
if (!v || digitsOnly(v).length === 0) {
|
||||
return required
|
||||
? { valid: false, message: 'CEP é obrigatório.' }
|
||||
: { valid: true, message: '' }
|
||||
}
|
||||
if (!isValidCEP(v)) return { valid: false, message: 'CEP inválido. Use 00000-000.' }
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
/** Nome completo — campo: `nome_completo` */
|
||||
function validateNomeCompleto(v, { required = true, minWords = 2 } = {}) {
|
||||
const s = String(v ?? '').trim()
|
||||
if (!s) {
|
||||
return required
|
||||
? { valid: false, message: 'Nome completo é obrigatório.' }
|
||||
: { valid: true, message: '' }
|
||||
}
|
||||
const words = s.split(/\s+/).filter(Boolean)
|
||||
if (words.length < minWords) return { valid: false, message: 'Informe o nome completo (mínimo 2 palavras).' }
|
||||
return { valid: true, message: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida um objeto de formulário de paciente de uma só vez.
|
||||
* Retorna { valid: boolean, errors: { campo: mensagem } }
|
||||
*
|
||||
* Exemplo:
|
||||
* const { valid, errors } = validatePatientForm(form, { cpfRequired: false })
|
||||
*/
|
||||
function validatePatientForm(form, { cpfRequired = false, emailRequired = false, phoneRequired = false } = {}) {
|
||||
const errors = {}
|
||||
|
||||
const nome = validateNomeCompleto(form.nome_completo)
|
||||
if (!nome.valid) errors.nome_completo = nome.message
|
||||
|
||||
if (form.cpf || cpfRequired) {
|
||||
const cpf = validateCPF(form.cpf, { required: cpfRequired })
|
||||
if (!cpf.valid) errors.cpf = cpf.message
|
||||
}
|
||||
|
||||
if (form.cpf_responsavel) {
|
||||
const cpfResp = validateCPF(form.cpf_responsavel)
|
||||
if (!cpfResp.valid) errors.cpf_responsavel = cpfResp.message
|
||||
}
|
||||
|
||||
if (form.telefone || phoneRequired) {
|
||||
const tel = validatePhone(form.telefone, { required: phoneRequired })
|
||||
if (!tel.valid) errors.telefone = tel.message
|
||||
}
|
||||
|
||||
if (form.telefone_alternativo) {
|
||||
const telAlt = validatePhone(form.telefone_alternativo)
|
||||
if (!telAlt.valid) errors.telefone_alternativo = telAlt.message
|
||||
}
|
||||
|
||||
if (form.telefone_parente) {
|
||||
const telPar = validatePhone(form.telefone_parente)
|
||||
if (!telPar.valid) errors.telefone_parente = telPar.message
|
||||
}
|
||||
|
||||
if (form.telefone_responsavel) {
|
||||
const telResp = validatePhone(form.telefone_responsavel)
|
||||
if (!telResp.valid) errors.telefone_responsavel = telResp.message
|
||||
}
|
||||
|
||||
if (form.email_principal || emailRequired) {
|
||||
const email = validateEmail(form.email_principal, { required: emailRequired })
|
||||
if (!email.valid) errors.email_principal = email.message
|
||||
}
|
||||
|
||||
if (form.email_alternativo) {
|
||||
const emailAlt = validateEmail(form.email_alternativo)
|
||||
if (!emailAlt.valid) errors.email_alternativo = emailAlt.message
|
||||
}
|
||||
|
||||
if (form.cep) {
|
||||
const cep = validateCEP(form.cep)
|
||||
if (!cep.valid) errors.cep = cep.message
|
||||
}
|
||||
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
return {
|
||||
// Validadores individuais
|
||||
validateCPF,
|
||||
validateCNPJ,
|
||||
validatePhone,
|
||||
validateEmail,
|
||||
validateCEP,
|
||||
validateNomeCompleto,
|
||||
|
||||
// Validação completa do formulário de paciente
|
||||
validatePatientForm,
|
||||
|
||||
// Re-exporta formatadores para usar junto
|
||||
fmtCPF,
|
||||
fmtCNPJ,
|
||||
fmtPhone,
|
||||
fmtCEP,
|
||||
|
||||
// Re-exporta utilitários
|
||||
sanitizeDigits,
|
||||
toISODate,
|
||||
digitsOnly,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/DocumentTemplatesPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { useDocumentTemplates } from './composables/useDocumentTemplates'
|
||||
import DocumentTemplateEditor from './components/DocumentTemplateEditor.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const {
|
||||
templates, loading, error,
|
||||
globalTemplates, tenantTemplates,
|
||||
TIPOS_TEMPLATE,
|
||||
fetchTemplates, create, update, remove, duplicate
|
||||
} = useDocumentTemplates()
|
||||
|
||||
// ── Views ───────────────────────────────────────────────────
|
||||
|
||||
const view = ref('list') // list | create | edit
|
||||
const editingTemplate = ref({})
|
||||
const editingId = ref(null)
|
||||
|
||||
// ── Mobile menu ─────────────────────────────────────────────
|
||||
|
||||
const mobileMenuRef = ref(null)
|
||||
const mobileMenuItems = [
|
||||
{ label: 'Novo template', icon: 'pi pi-plus', command: () => openCreate() },
|
||||
{ separator: true },
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchTemplates(true) }
|
||||
]
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────
|
||||
|
||||
onMounted(() => fetchTemplates(true))
|
||||
|
||||
// ── Acoes ───────────────────────────────────────────────────
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
editingTemplate.value = {}
|
||||
view.value = 'create'
|
||||
}
|
||||
|
||||
function openEdit(tpl) {
|
||||
if (tpl.is_global) {
|
||||
toast.add({ severity: 'warn', summary: 'Somente leitura', detail: 'Templates padrão não podem ser editados. Duplique para personalizar.', life: 3000 })
|
||||
return
|
||||
}
|
||||
editingId.value = tpl.id
|
||||
editingTemplate.value = { ...tpl }
|
||||
view.value = 'edit'
|
||||
}
|
||||
|
||||
async function onSave(payload) {
|
||||
try {
|
||||
if (view.value === 'create') {
|
||||
await create(payload)
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: payload.nome_template, life: 3000 })
|
||||
} else {
|
||||
await update(editingId.value, payload)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: payload.nome_template, life: 3000 })
|
||||
}
|
||||
view.value = 'list'
|
||||
fetchTemplates(true)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
function onDuplicate(tpl) {
|
||||
confirm.require({
|
||||
message: `Deseja copiar "${tpl.nome_template}" para os seus templates? Você poderá editá-lo livremente.`,
|
||||
header: 'Duplicar template',
|
||||
icon: 'pi pi-copy',
|
||||
acceptLabel: 'Copiar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
await duplicate(tpl.id)
|
||||
toast.add({ severity: 'success', summary: 'Duplicado', detail: `"${tpl.nome_template}" copiado para Meus Templates.`, life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onDelete(tpl) {
|
||||
confirm.require({
|
||||
message: `Desativar template "${tpl.nome_template}"?`,
|
||||
header: 'Confirmar',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
accept: async () => {
|
||||
try {
|
||||
await remove(tpl.id)
|
||||
toast.add({ severity: 'success', summary: 'Desativado', life: 2000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
view.value = 'list'
|
||||
}
|
||||
|
||||
// ── Tipo label ──────────────────────────────────────────────
|
||||
|
||||
function tipoLabel(tipo) {
|
||||
return TIPOS_TEMPLATE.find(t => t.value === tipo)?.label || tipo
|
||||
}
|
||||
|
||||
// ── Template card menu ──────────────────────────────────────
|
||||
|
||||
function getCardMenuItems(tpl) {
|
||||
const items = [
|
||||
{ label: 'Duplicar', icon: 'pi pi-copy', command: () => onDuplicate(tpl) }
|
||||
]
|
||||
if (!tpl.is_global) {
|
||||
items.push(
|
||||
{ label: 'Editar', icon: 'pi pi-pencil', command: () => openEdit(tpl) },
|
||||
{ separator: true },
|
||||
{ label: 'Desativar', icon: 'pi pi-trash', class: 'text-red-500', command: () => onDelete(tpl) }
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-6 max-w-[1200px] mx-auto">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="view !== 'list'"
|
||||
icon="pi pi-arrow-left"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="view = 'list'"
|
||||
/>
|
||||
<h1 class="text-xl font-bold">
|
||||
<template v-if="view === 'list'">Templates de documentos</template>
|
||||
<template v-else-if="view === 'create'">Novo template</template>
|
||||
<template v-else>Editar template</template>
|
||||
</h1>
|
||||
</div>
|
||||
<p v-if="view === 'list'" class="text-sm text-[var(--text-color-secondary)]">
|
||||
Modelos para declarações, atestados, recibos e outros documentos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="view === 'list'" class="hidden sm:flex items-center gap-2">
|
||||
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openCreate" />
|
||||
</div>
|
||||
<div v-if="view === 'list'" class="sm:hidden">
|
||||
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<template v-if="view === 'list'">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!templates.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-file-edit text-4xl opacity-30 mb-3" />
|
||||
<div class="text-sm mb-1">Nenhum template encontrado.</div>
|
||||
<Button label="Criar primeiro template" icon="pi pi-plus" text size="small" class="mt-2" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Templates globais (padrao) -->
|
||||
<div v-if="globalTemplates.length" class="mb-6">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
|
||||
Templates padrão do sistema
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="tpl in globalTemplates"
|
||||
:key="tpl.id"
|
||||
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)] transition-all cursor-pointer"
|
||||
@click="onDuplicate(tpl)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<i class="pi pi-file text-blue-500" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="absolute top-2 right-2 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600">
|
||||
padrão
|
||||
</span>
|
||||
<div class="mt-2 text-[0.65rem] text-[var(--text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Clique para duplicar e personalizar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates do tenant -->
|
||||
<div v-if="tenantTemplates.length">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
|
||||
Meus templates
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="tpl in tenantTemplates"
|
||||
:key="tpl.id"
|
||||
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 transition-all cursor-pointer"
|
||||
@click="openEdit(tpl)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<i class="pi pi-file-edit text-primary" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu de acoes -->
|
||||
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7"
|
||||
@click.stop="$refs[`menu_${tpl.id}`]?.[0]?.toggle($event)"
|
||||
/>
|
||||
<Menu :ref="`menu_${tpl.id}`" :model="getCardMenuItems(tpl)" :popup="true" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
v-if="!tpl.ativo"
|
||||
class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-500"
|
||||
>
|
||||
inativo
|
||||
</span>
|
||||
<span class="text-[0.6rem] text-[var(--text-color-secondary)]">
|
||||
{{ tpl.variaveis?.length || 0 }} variáveis
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Create / Edit view -->
|
||||
<template v-if="view === 'create' || view === 'edit'">
|
||||
<DocumentTemplateEditor
|
||||
v-model="editingTemplate"
|
||||
:mode="view"
|
||||
@save="onSave"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,377 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/DocumentsListPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { useDocuments } from './composables/useDocuments'
|
||||
import DocumentCard from './components/DocumentCard.vue'
|
||||
import DocumentUploadDialog from './components/DocumentUploadDialog.vue'
|
||||
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
|
||||
import DocumentGenerateDialog from './components/DocumentGenerateDialog.vue'
|
||||
import DocumentSignatureDialog from './components/DocumentSignatureDialog.vue'
|
||||
import DocumentShareDialog from './components/DocumentShareDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── Props (pode receber patientId via route ou prop) ────────
|
||||
const props = defineProps({
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' },
|
||||
embedded: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const resolvedPatientId = computed(() => props.patientId || route.params.id || null)
|
||||
|
||||
const {
|
||||
documents, loading, error, filters, usedTags, stats,
|
||||
TIPOS_DOCUMENTO,
|
||||
fetchDocuments, upload, update, remove, restore,
|
||||
download, getPreviewUrl, fetchUsedTags, clearFilters,
|
||||
formatSize, mimeIcon
|
||||
} = useDocuments(() => resolvedPatientId.value)
|
||||
|
||||
// ── Dialogs ─────────────────────────────────────────────────
|
||||
|
||||
const uploadDlg = ref(false)
|
||||
const previewDlg = ref(false)
|
||||
const generateDlg = ref(false)
|
||||
const signatureDlg = ref(false)
|
||||
const shareDlg = ref(false)
|
||||
|
||||
const selectedDoc = ref(null)
|
||||
const previewUrl = ref('')
|
||||
|
||||
// ── Mobile menu ─────────────────────────────────────────────
|
||||
|
||||
const mobileMenuRef = ref(null)
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Upload', icon: 'pi pi-upload', command: () => uploadDlg.value = true },
|
||||
{ label: 'Gerar documento', icon: 'pi pi-file-pdf', command: () => generateDlg.value = true },
|
||||
{ separator: true },
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchDocuments() }
|
||||
])
|
||||
|
||||
// ── Hero sticky ─────────────────────────────────────────────
|
||||
|
||||
const headerEl = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchDocuments(), fetchUsedTags()])
|
||||
})
|
||||
|
||||
// ── Acoes ───────────────────────────────────────────────────
|
||||
|
||||
async function onUploaded({ file, meta }) {
|
||||
try {
|
||||
await upload(file, resolvedPatientId.value, meta)
|
||||
toast.add({ severity: 'success', summary: 'Enviado', detail: file.name, life: 3000 })
|
||||
fetchUsedTags()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function onPreview(doc) {
|
||||
selectedDoc.value = doc
|
||||
try {
|
||||
previewUrl.value = await getPreviewUrl(doc)
|
||||
} catch {
|
||||
previewUrl.value = ''
|
||||
}
|
||||
previewDlg.value = true
|
||||
}
|
||||
|
||||
function onDownload(doc) {
|
||||
download(doc)
|
||||
}
|
||||
|
||||
function onEdit(doc) {
|
||||
selectedDoc.value = doc
|
||||
// TODO: abrir dialog de edicao de metadados
|
||||
toast.add({ severity: 'info', summary: 'Em breve', detail: 'Edição de metadados será implementada.', life: 2000 })
|
||||
}
|
||||
|
||||
function onDelete(doc) {
|
||||
confirm.require({
|
||||
message: `Excluir "${doc.nome_original}"? O arquivo será retido por 5 anos conforme LGPD/CFP.`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await remove(doc.id)
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: doc.nome_original, life: 2000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onShare(doc) {
|
||||
selectedDoc.value = doc
|
||||
shareDlg.value = true
|
||||
}
|
||||
|
||||
function onSign(doc) {
|
||||
selectedDoc.value = doc
|
||||
signatureDlg.value = true
|
||||
}
|
||||
|
||||
function onGenerated() {
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
// ── Computed: filtro ativo ───────────────────────────────────
|
||||
|
||||
const hasActiveFilter = computed(() =>
|
||||
filters.value.tipo_documento || filters.value.tag || filters.value.search
|
||||
)
|
||||
|
||||
// ── Watch filtros ───────────────────────────────────────────
|
||||
|
||||
watch(filters, () => fetchDocuments(), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'px-4 py-6 max-w-[1200px] mx-auto'">
|
||||
|
||||
<!-- Hero header -->
|
||||
<div
|
||||
v-if="!embedded"
|
||||
ref="headerEl"
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6"
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">Documentos</h1>
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
{{ resolvedPatientId ? patientName || 'Paciente' : 'Todos os pacientes' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Desktop actions -->
|
||||
<div class="hidden sm:flex items-center gap-2">
|
||||
<Button
|
||||
label="Gerar documento"
|
||||
icon="pi pi-file-pdf"
|
||||
outlined
|
||||
size="small"
|
||||
@click="generateDlg = true"
|
||||
:disabled="!resolvedPatientId"
|
||||
/>
|
||||
<Button
|
||||
label="Upload"
|
||||
icon="pi pi-upload"
|
||||
size="small"
|
||||
@click="uploadDlg = true"
|
||||
:disabled="!resolvedPatientId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="sm:hidden">
|
||||
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded header (dentro do prontuario) -->
|
||||
<div v-else class="flex items-center justify-between gap-2 mb-4">
|
||||
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
|
||||
<div class="flex gap-1.5">
|
||||
<Button
|
||||
icon="pi pi-file-pdf"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'Gerar documento'"
|
||||
@click="generateDlg = true"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-upload"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'Upload'"
|
||||
@click="uploadDlg = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div v-if="!embedded && documents.length" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<span class="text-lg font-bold">{{ stats.total }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<span class="text-lg font-bold">{{ formatSize(stats.tamanhoTotal) }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tamanho</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<span class="text-lg font-bold">{{ Object.keys(stats.porTipo).length }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tipos</span>
|
||||
</div>
|
||||
<div v-if="stats.pendentesRevisao" class="flex flex-col items-center p-3 rounded-lg bg-amber-500/5 border border-amber-500/20">
|
||||
<span class="text-lg font-bold text-amber-600">{{ stats.pendentesRevisao }}</span>
|
||||
<span class="text-[0.65rem] text-amber-600 uppercase tracking-wider">Pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="filters.search"
|
||||
placeholder="Buscar..."
|
||||
class="!w-[200px]"
|
||||
size="small"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<Select
|
||||
v-model="filters.tipo_documento"
|
||||
:options="TIPOS_DOCUMENTO"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Tipo"
|
||||
showClear
|
||||
class="!w-[160px]"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-if="usedTags.length"
|
||||
v-model="filters.tag"
|
||||
:options="usedTags.map(t => ({ label: t, value: t }))"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Tag"
|
||||
showClear
|
||||
class="!w-[140px]"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="hasActiveFilter"
|
||||
icon="pi pi-filter-slash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'Limpar filtros'"
|
||||
@click="clearFilters(); fetchDocuments()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!documents.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-inbox text-4xl opacity-30 mb-3" />
|
||||
<div class="text-sm mb-1">
|
||||
{{ hasActiveFilter ? 'Nenhum documento encontrado com esses filtros.' : 'Nenhum documento ainda.' }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="resolvedPatientId && !hasActiveFilter"
|
||||
label="Enviar primeiro documento"
|
||||
icon="pi pi-upload"
|
||||
text
|
||||
size="small"
|
||||
class="mt-2"
|
||||
@click="uploadDlg = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Lista de documentos -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<DocumentCard
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
:doc="doc"
|
||||
@preview="onPreview"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@share="onShare"
|
||||
@sign="onSign"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="mt-4 p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-sm text-red-500">
|
||||
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<DocumentUploadDialog
|
||||
:visible="uploadDlg"
|
||||
@update:visible="uploadDlg = $event"
|
||||
:patientId="resolvedPatientId"
|
||||
:patientName="patientName"
|
||||
:usedTags="usedTags"
|
||||
@uploaded="onUploaded"
|
||||
/>
|
||||
|
||||
<DocumentPreviewDialog
|
||||
:visible="previewDlg"
|
||||
@update:visible="previewDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
:previewUrl="previewUrl"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@delete="d => { previewDlg = false; onDelete(d) }"
|
||||
@share="d => { previewDlg = false; onShare(d) }"
|
||||
@sign="d => { previewDlg = false; onSign(d) }"
|
||||
/>
|
||||
|
||||
<DocumentGenerateDialog
|
||||
:visible="generateDlg"
|
||||
@update:visible="generateDlg = $event"
|
||||
:patientId="resolvedPatientId"
|
||||
:patientName="patientName"
|
||||
@generated="onGenerated"
|
||||
/>
|
||||
|
||||
<DocumentSignatureDialog
|
||||
:visible="signatureDlg"
|
||||
@update:visible="signatureDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
|
||||
<DocumentShareDialog
|
||||
:visible="shareDlg"
|
||||
@update:visible="shareDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentCard.vue
|
||||
| Card reutilizavel de documento — thumbnail, nome, tipo, data, tags, acoes.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doc: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['preview', 'download', 'edit', 'delete', 'share', 'sign'])
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
const mimeIcon = computed(() => {
|
||||
const m = String(props.doc.mime_type || '')
|
||||
if (m.startsWith('image/')) return 'pi pi-image'
|
||||
if (m === 'application/pdf') return 'pi pi-file-pdf'
|
||||
if (m.includes('word') || m.includes('document')) return 'pi pi-file-word'
|
||||
if (m.includes('excel') || m.includes('sheet')) return 'pi pi-file-excel'
|
||||
return 'pi pi-file'
|
||||
})
|
||||
|
||||
const mimeColor = computed(() => {
|
||||
const m = String(props.doc.mime_type || '')
|
||||
if (m.startsWith('image/')) return 'bg-purple-500/10 text-purple-500'
|
||||
if (m === 'application/pdf') return 'bg-red-500/10 text-red-500'
|
||||
if (m.includes('word')) return 'bg-blue-500/10 text-blue-500'
|
||||
if (m.includes('excel')) return 'bg-green-500/10 text-green-500'
|
||||
return 'bg-gray-500/10 text-gray-500'
|
||||
})
|
||||
|
||||
const tipoLabel = computed(() => {
|
||||
const map = {
|
||||
laudo: 'Laudo', receita: 'Receita', exame: 'Exame',
|
||||
termo_assinado: 'Termo', relatorio_externo: 'Relatório',
|
||||
identidade: 'Identidade', convenio: 'Convênio',
|
||||
declaracao: 'Declaração', atestado: 'Atestado',
|
||||
recibo: 'Recibo', outro: 'Outro'
|
||||
}
|
||||
return map[props.doc.tipo_documento] || 'Documento'
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
const b = props.doc.tamanho_bytes
|
||||
if (!b) return '—'
|
||||
if (b < 1024) return b + ' B'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
||||
return (b / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const d = props.doc.uploaded_at
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
})
|
||||
|
||||
const isImage = computed(() => String(props.doc.mime_type || '').startsWith('image/'))
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{ label: 'Visualizar', icon: 'pi pi-eye', command: () => emit('preview', props.doc) },
|
||||
{ label: 'Baixar', icon: 'pi pi-download', command: () => emit('download', props.doc) },
|
||||
{ label: 'Editar', icon: 'pi pi-pencil', command: () => emit('edit', props.doc) },
|
||||
{ label: 'Compartilhar', icon: 'pi pi-share-alt', command: () => emit('share', props.doc) },
|
||||
{ label: 'Assinar', icon: 'pi pi-check-square', command: () => emit('sign', props.doc) },
|
||||
{ separator: true },
|
||||
{ label: 'Excluir', icon: 'pi pi-trash', class: 'text-red-500', command: () => emit('delete', props.doc) }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative flex items-start gap-3 p-3 rounded-lg border transition-all cursor-pointer"
|
||||
:class="[
|
||||
selected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)]'
|
||||
]"
|
||||
@click="emit('preview', doc)"
|
||||
>
|
||||
<!-- Icone / Thumbnail -->
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="mimeColor">
|
||||
<i :class="mimeIcon" class="text-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate">{{ doc.nome_original }}</span>
|
||||
<span
|
||||
v-if="doc.enviado_pelo_paciente"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-600 whitespace-nowrap"
|
||||
>
|
||||
paciente
|
||||
</span>
|
||||
<span
|
||||
v-if="doc.status_revisao === 'pendente'"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600 whitespace-nowrap"
|
||||
>
|
||||
pendente
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-[var(--text-color-secondary)]">
|
||||
<span>{{ tipoLabel }}</span>
|
||||
<span class="opacity-30">|</span>
|
||||
<span>{{ formattedSize }}</span>
|
||||
<span class="opacity-30">|</span>
|
||||
<span>{{ formattedDate }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="doc.tags?.length" class="flex flex-wrap gap-1 mt-1.5">
|
||||
<span
|
||||
v-for="tag in doc.tags"
|
||||
:key="tag"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full border border-[var(--surface-border)] text-[var(--text-color-secondary)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu de acoes -->
|
||||
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7"
|
||||
@click.stop="$refs.menu.toggle($event)"
|
||||
/>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true" />
|
||||
</div>
|
||||
|
||||
<!-- Badges de visibilidade -->
|
||||
<div class="absolute top-2 right-2 flex gap-1" v-if="doc.compartilhado_portal || doc.compartilhado_supervisor">
|
||||
<i
|
||||
v-if="doc.compartilhado_portal"
|
||||
class="pi pi-user text-[0.6rem] p-1 rounded-full bg-blue-500/10 text-blue-500"
|
||||
v-tooltip.top="'Visível no portal do paciente'"
|
||||
/>
|
||||
<i
|
||||
v-if="doc.compartilhado_supervisor"
|
||||
class="pi pi-eye text-[0.6rem] p-1 rounded-full bg-teal-500/10 text-teal-500"
|
||||
v-tooltip.top="'Compartilhado com supervisor'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,266 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentGenerateDialog.vue
|
||||
| Gerar documento: selecionar template, preencher, preview, gerar PDF.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useDocumentGenerate } from '../composables/useDocumentGenerate'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' },
|
||||
agendaEventoId: { type: String, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'generated'])
|
||||
|
||||
const toast = useToast()
|
||||
const step = ref('select') // select | edit | preview
|
||||
|
||||
const {
|
||||
loading: generating,
|
||||
error: genError,
|
||||
variables,
|
||||
selectedTemplate,
|
||||
previewHtml,
|
||||
loadVariables,
|
||||
selectTemplate,
|
||||
setVariable,
|
||||
updatePreview,
|
||||
generateAndSave,
|
||||
downloadOnly,
|
||||
printDocument,
|
||||
reset
|
||||
} = useDocumentGenerate()
|
||||
|
||||
const {
|
||||
templates,
|
||||
loading: loadingTemplates,
|
||||
fetchTemplates,
|
||||
TEMPLATE_VARIABLES
|
||||
} = useDocumentTemplates()
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
step.value = 'select'
|
||||
reset()
|
||||
await Promise.all([
|
||||
fetchTemplates(),
|
||||
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
// ── Selecionar template ─────────────────────────────────────
|
||||
|
||||
async function onSelectTemplate(tpl) {
|
||||
await selectTemplate(tpl.id)
|
||||
step.value = 'edit'
|
||||
}
|
||||
|
||||
// ── Variaveis editaveis ─────────────────────────────────────
|
||||
|
||||
const editableVars = computed(() => {
|
||||
if (!selectedTemplate.value?.variaveis?.length) return []
|
||||
return selectedTemplate.value.variaveis.map(key => {
|
||||
const meta = TEMPLATE_VARIABLES.find(v => v.key === key)
|
||||
return {
|
||||
key,
|
||||
label: meta?.label || key,
|
||||
grupo: meta?.grupo || 'Outros',
|
||||
value: variables.value[key] || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const varGroups = computed(() => {
|
||||
const groups = {}
|
||||
for (const v of editableVars.value) {
|
||||
if (!groups[v.grupo]) groups[v.grupo] = []
|
||||
groups[v.grupo].push(v)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function onVarChange(key, val) {
|
||||
setVariable(key, val)
|
||||
}
|
||||
|
||||
// ── Gerar ───────────────────────────────────────────────────
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
const result = await generateAndSave(props.patientId)
|
||||
toast.add({ severity: 'success', summary: 'Documento salvo', detail: 'Disponível nos documentos do paciente.', life: 3000 })
|
||||
emit('generated', result)
|
||||
close()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar PDF.' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDownloadOnly() {
|
||||
try {
|
||||
await downloadOnly()
|
||||
toast.add({ severity: 'info', summary: 'Download', detail: 'PDF baixado (não salvo no sistema).', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar PDF.' })
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
maximizable
|
||||
:draggable="false"
|
||||
:closable="!generating"
|
||||
:dismissableMask="!generating"
|
||||
class="w-[60rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '96vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-green-500/10">
|
||||
<i class="pi pi-file-pdf text-green-600" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Gerar documento</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
<template v-if="step === 'select'">Selecione um template</template>
|
||||
<template v-else-if="step === 'edit'">{{ selectedTemplate?.nome_template }} — {{ patientName }}</template>
|
||||
<template v-else>Preview do documento</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 1: Selecionar template -->
|
||||
<div v-if="step === 'select'">
|
||||
<div v-if="loadingTemplates" class="flex items-center justify-center py-12">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
<div v-else-if="!templates.length" class="text-center py-12 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-inbox text-3xl opacity-40 mb-2" />
|
||||
<div class="text-sm">Nenhum template disponível.</div>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
class="flex items-start gap-3 p-3 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 text-left transition-all"
|
||||
@click="onSelectTemplate(tpl)"
|
||||
>
|
||||
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<i class="pi pi-file text-primary" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tpl.descricao || tpl.tipo }}</div>
|
||||
<span
|
||||
v-if="tpl.is_global"
|
||||
class="inline-block mt-1 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600"
|
||||
>
|
||||
padrão
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Editar variaveis -->
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-4">
|
||||
<div v-for="(vars, grupo) in varGroups" :key="grupo">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div v-for="v in vars" :key="v.key" class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">{{ v.label }}</label>
|
||||
<InputText
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<Button label="Voltar" text icon="pi pi-arrow-left" @click="step = 'select'; reset()" />
|
||||
<Button label="Preview" icon="pi pi-eye" @click="updatePreview(); step = 'preview'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Preview -->
|
||||
<div v-else-if="step === 'preview'">
|
||||
<div class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
|
||||
<iframe
|
||||
:srcdoc="previewHtml"
|
||||
class="w-full min-h-[60vh] border-0"
|
||||
sandbox=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="genError" class="mt-3 text-sm text-red-500 flex items-center gap-1.5">
|
||||
<i class="pi pi-exclamation-circle text-xs" />
|
||||
{{ genError }}
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Editar"
|
||||
text
|
||||
icon="pi pi-arrow-left"
|
||||
@click="step = 'edit'"
|
||||
:disabled="generating"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Cancelar" text @click="close" :disabled="generating" />
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Só baixar"
|
||||
text
|
||||
icon="pi pi-download"
|
||||
@click="onDownloadOnly"
|
||||
:loading="generating"
|
||||
/>
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Salvar documento"
|
||||
icon="pi pi-check"
|
||||
@click="onGenerate"
|
||||
:loading="generating"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentPreviewDialog.vue
|
||||
| Preview inline de PDF/imagem + metadados + acoes.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null },
|
||||
previewUrl: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'download', 'edit', 'delete', 'share', 'sign'])
|
||||
|
||||
const toast = useToast()
|
||||
const activeTab = ref('preview')
|
||||
|
||||
// ── Computed ────────────────────────────────────────────────
|
||||
|
||||
const isImage = computed(() => String(props.doc?.mime_type || '').startsWith('image/'))
|
||||
const isPdf = computed(() => props.doc?.mime_type === 'application/pdf')
|
||||
const canPreview = computed(() => isImage.value || isPdf.value)
|
||||
|
||||
const tipoLabel = computed(() => {
|
||||
const map = {
|
||||
laudo: 'Laudo', receita: 'Receita', exame: 'Exame',
|
||||
termo_assinado: 'Termo assinado', relatorio_externo: 'Relatório externo',
|
||||
identidade: 'Identidade', convenio: 'Convênio',
|
||||
declaracao: 'Declaração', atestado: 'Atestado',
|
||||
recibo: 'Recibo', outro: 'Outro'
|
||||
}
|
||||
return map[props.doc?.tipo_documento] || 'Documento'
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
const b = props.doc?.tamanho_bytes
|
||||
if (!b) return '—'
|
||||
if (b < 1024) return b + ' B'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
||||
return (b / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const d = props.doc?.uploaded_at
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
|
||||
const visibilidadeLabel = computed(() => {
|
||||
const map = {
|
||||
privado: 'Privado',
|
||||
compartilhado_supervisor: 'Supervisor',
|
||||
compartilhado_portal: 'Portal paciente'
|
||||
}
|
||||
return map[props.doc?.visibilidade] || 'Privado'
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
maximizable
|
||||
:draggable="false"
|
||||
class="w-[55rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '96vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-0' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-500/10">
|
||||
<i class="pi pi-eye text-indigo-500" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">{{ doc?.nome_original }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">{{ tipoLabel }} · {{ formattedSize }} · {{ formattedDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="doc" class="flex flex-col lg:flex-row">
|
||||
<!-- Preview area -->
|
||||
<div class="flex-1 min-h-[400px] flex items-center justify-center bg-[var(--surface-ground)] p-4">
|
||||
<template v-if="canPreview && previewUrl">
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="previewUrl"
|
||||
:alt="doc.nome_original"
|
||||
class="max-w-full max-h-[70vh] rounded shadow-sm"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="previewUrl"
|
||||
class="w-full h-[70vh] rounded border-0"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="flex flex-col items-center gap-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-file text-5xl opacity-40" />
|
||||
<span class="text-sm">Preview não disponível para este tipo de arquivo.</span>
|
||||
<Button label="Baixar arquivo" icon="pi pi-download" size="small" @click="emit('download', doc)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar de detalhes -->
|
||||
<div class="w-full lg:w-[240px] border-t lg:border-t-0 lg:border-l border-[var(--surface-border)] p-4 flex flex-col gap-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">Detalhes</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Tipo</div>
|
||||
<div class="text-sm">{{ tipoLabel }}</div>
|
||||
</div>
|
||||
<div v-if="doc.categoria">
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Categoria</div>
|
||||
<div class="text-sm">{{ doc.categoria }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Visibilidade</div>
|
||||
<div class="text-sm">{{ visibilidadeLabel }}</div>
|
||||
</div>
|
||||
<div v-if="doc.descricao">
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Descrição</div>
|
||||
<div class="text-sm">{{ doc.descricao }}</div>
|
||||
</div>
|
||||
<div v-if="doc.tags?.length">
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">Tags</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in doc.tags"
|
||||
:key="tag"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acoes -->
|
||||
<div class="mt-auto flex flex-col gap-1.5 pt-3 border-t border-[var(--surface-border)]">
|
||||
<Button label="Baixar" icon="pi pi-download" size="small" class="w-full" @click="emit('download', doc)" />
|
||||
<Button label="Editar" icon="pi pi-pencil" size="small" text class="w-full" @click="emit('edit', doc)" />
|
||||
<Button label="Compartilhar" icon="pi pi-share-alt" size="small" text class="w-full" @click="emit('share', doc)" />
|
||||
<Button label="Assinar" icon="pi pi-check-square" size="small" text class="w-full" @click="emit('sign', doc)" />
|
||||
<Button label="Excluir" icon="pi pi-trash" size="small" text severity="danger" class="w-full" @click="emit('delete', doc)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<Button label="Fechar" text @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,245 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentShareDialog.vue
|
||||
| Gerar link temporario para compartilhamento externo.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
createShareLink,
|
||||
listShareLinks,
|
||||
deactivateShareLink,
|
||||
buildShareUrl
|
||||
} from '@/services/DocumentShareLinks.service'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const links = ref([])
|
||||
|
||||
const OPCOES_EXPIRACAO = [
|
||||
{ value: 24, label: '24 horas' },
|
||||
{ value: 48, label: '48 horas' },
|
||||
{ value: 168, label: '7 dias' },
|
||||
{ value: 720, label: '30 dias' }
|
||||
]
|
||||
|
||||
const formExpiracao = ref(48)
|
||||
const formUsosMax = ref(5)
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v && props.doc) {
|
||||
formExpiracao.value = 48
|
||||
formUsosMax.value = 5
|
||||
await fetchLinks()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchLinks() {
|
||||
loading.value = true
|
||||
try {
|
||||
links.value = await listShareLinks(props.doc.id)
|
||||
} catch {
|
||||
links.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Criar link ──────────────────────────────────────────────
|
||||
|
||||
async function criarLink() {
|
||||
creating.value = true
|
||||
try {
|
||||
const link = await createShareLink(props.doc.id, {
|
||||
expiracaoHoras: formExpiracao.value,
|
||||
usosMax: formUsosMax.value
|
||||
})
|
||||
links.value.unshift(link)
|
||||
toast.add({ severity: 'success', summary: 'Link criado', detail: 'Link copiado para a área de transferência.', life: 3000 })
|
||||
copyUrl(link.token)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar link.' })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copiar URL ──────────────────────────────────────────────
|
||||
|
||||
function copyUrl(token) {
|
||||
const url = buildShareUrl(token)
|
||||
navigator.clipboard.writeText(url).catch(() => {})
|
||||
}
|
||||
|
||||
// ── Desativar link ──────────────────────────────────────────
|
||||
|
||||
async function desativar(linkId) {
|
||||
try {
|
||||
await deactivateShareLink(linkId)
|
||||
const idx = links.value.findIndex(l => l.id === linkId)
|
||||
if (idx >= 0) links.value[idx].ativo = false
|
||||
toast.add({ severity: 'info', summary: 'Link desativado', life: 2000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function isExpired(link) {
|
||||
return new Date(link.expira_em) < new Date()
|
||||
}
|
||||
|
||||
function isExhausted(link) {
|
||||
return link.usos >= link.usos_max
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
:draggable="false"
|
||||
class="w-[36rem]"
|
||||
:breakpoints="{ '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-orange-500/10">
|
||||
<i class="pi pi-share-alt text-orange-500" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Compartilhar documento</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate max-w-[300px]">{{ doc?.nome_original }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Criar novo link -->
|
||||
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">Novo link</div>
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Expira em</label>
|
||||
<Select
|
||||
v-model="formExpiracao"
|
||||
:options="OPCOES_EXPIRACAO"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Limite de acessos</label>
|
||||
<InputNumber v-model="formUsosMax" :min="1" :max="100" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Gerar link"
|
||||
icon="pi pi-link"
|
||||
size="small"
|
||||
:loading="creating"
|
||||
@click="criarLink"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Links existentes -->
|
||||
<div v-if="links.length">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">Links criados</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 max-h-[250px] overflow-y-auto">
|
||||
<div
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
class="flex items-center gap-2 p-2.5 rounded-md border border-[var(--surface-border)]"
|
||||
:class="{ 'opacity-50': !link.ativo || isExpired(link) || isExhausted(link) }"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
!link.ativo ? 'pi pi-ban text-gray-400' :
|
||||
isExpired(link) ? 'pi pi-clock text-red-400' :
|
||||
isExhausted(link) ? 'pi pi-exclamation-circle text-amber-400' :
|
||||
'pi pi-link text-green-500'
|
||||
"
|
||||
class="text-sm flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
Expira: {{ formatDate(link.expira_em) }}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
Usos: {{ link.usos }}/{{ link.usos_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
v-if="link.ativo && !isExpired(link)"
|
||||
icon="pi pi-copy"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7"
|
||||
v-tooltip.top="'Copiar link'"
|
||||
@click="copyUrl(link.token)"
|
||||
/>
|
||||
<Button
|
||||
v-if="link.ativo"
|
||||
icon="pi pi-ban"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
class="!w-7 !h-7"
|
||||
v-tooltip.top="'Desativar'"
|
||||
@click="desativar(link.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<Button label="Fechar" text @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,306 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentSignatureDialog.vue
|
||||
| Solicitar assinatura: adicionar signatarios, acompanhar status.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import {
|
||||
createSignatureRequests,
|
||||
listSignatures,
|
||||
getSignatureStatus
|
||||
} from '@/services/DocumentSignatures.service'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'requested'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const existingSignatures = ref([])
|
||||
const signatureStatus = ref(null)
|
||||
|
||||
const TIPOS_SIGNATARIO = [
|
||||
{ value: 'paciente', label: 'Paciente' },
|
||||
{ value: 'responsavel_legal', label: 'Responsável legal' },
|
||||
{ value: 'terapeuta', label: 'Terapeuta' }
|
||||
]
|
||||
|
||||
// Signatarios a adicionar
|
||||
const signatarios = ref([])
|
||||
const patientEmails = ref([])
|
||||
|
||||
function addSignatario() {
|
||||
signatarios.value.push({ tipo: 'paciente', nome: '', email: '' })
|
||||
}
|
||||
|
||||
function removeSignatario(idx) {
|
||||
signatarios.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
// ── Buscar emails do paciente ──────────────────────────────
|
||||
|
||||
async function fetchPatientEmails(patientId) {
|
||||
if (!patientId) { patientEmails.value = []; return }
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('patients')
|
||||
.select('email_principal, email_alternativo')
|
||||
.eq('id', patientId)
|
||||
.single()
|
||||
|
||||
const emails = []
|
||||
if (data?.email_principal) emails.push(data.email_principal)
|
||||
if (data?.email_alternativo && data.email_alternativo !== data.email_principal) emails.push(data.email_alternativo)
|
||||
patientEmails.value = emails
|
||||
} catch {
|
||||
patientEmails.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function useEmail(email) {
|
||||
// Preenche o último signatário adicionado que não tenha email, ou o primeiro vazio
|
||||
const target = signatarios.value.findLast(s => !s.email?.trim()) || signatarios.value[signatarios.value.length - 1]
|
||||
if (target) target.email = email
|
||||
}
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v && props.doc) {
|
||||
signatarios.value = []
|
||||
loading.value = true
|
||||
try {
|
||||
const [sigs, status] = await Promise.all([
|
||||
listSignatures(props.doc.id),
|
||||
getSignatureStatus(props.doc.id),
|
||||
fetchPatientEmails(props.doc.patient_id)
|
||||
])
|
||||
existingSignatures.value = sigs
|
||||
signatureStatus.value = status
|
||||
} catch {
|
||||
existingSignatures.value = []
|
||||
signatureStatus.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Status badge ────────────────────────────────────────────
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const s = signatureStatus.value?.status
|
||||
if (s === 'completo') return 'bg-green-500/10 text-green-600'
|
||||
if (s === 'parcial') return 'bg-amber-500/10 text-amber-600'
|
||||
return 'bg-gray-500/10 text-gray-500'
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const s = signatureStatus.value?.status
|
||||
if (s === 'completo') return 'Todas assinaturas completas'
|
||||
if (s === 'parcial') return `${signatureStatus.value.assinados}/${signatureStatus.value.total} assinado(s)`
|
||||
if (s === 'pendente') return 'Aguardando assinaturas'
|
||||
return 'Sem assinaturas'
|
||||
})
|
||||
|
||||
// ── Enviar solicitacao ──────────────────────────────────────
|
||||
|
||||
async function submit() {
|
||||
if (!signatarios.value.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Adicione ao menos um signatário.' })
|
||||
return
|
||||
}
|
||||
|
||||
const semNome = signatarios.value.find(s => !s.nome?.trim())
|
||||
if (semNome) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preencha o nome de todos os signatários.' })
|
||||
return
|
||||
}
|
||||
|
||||
const semEmail = signatarios.value.find(s => !s.email?.trim())
|
||||
if (semEmail) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preencha o e-mail de todos os signatários.' })
|
||||
return
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const emailInvalido = signatarios.value.find(s => !emailRegex.test(s.email?.trim()))
|
||||
if (emailInvalido) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: `E-mail inválido: ${emailInvalido.email}` })
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await createSignatureRequests(props.doc.id, signatarios.value)
|
||||
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
|
||||
emit('requested', result)
|
||||
emit('update:visible', false)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao solicitar assinatura.' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
class="w-[38rem]"
|
||||
:breakpoints="{ '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-500/10">
|
||||
<i class="pi pi-check-square text-teal-600" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Assinatura eletrônica</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate max-w-[300px]">{{ doc?.nome_original }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<i class="pi pi-spinner pi-spin text-xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<!-- Status atual -->
|
||||
<div v-if="existingSignatures.length" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Assinaturas existentes</span>
|
||||
<span class="text-[0.65rem] px-2 py-0.5 rounded-full" :class="statusColor">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="sig in existingSignatures"
|
||||
:key="sig.id"
|
||||
class="flex items-center gap-2 p-2 rounded-md bg-[var(--surface-ground)]"
|
||||
>
|
||||
<i
|
||||
:class="sig.status === 'assinado' ? 'pi pi-check-circle text-green-500' : sig.status === 'recusado' ? 'pi pi-times-circle text-red-500' : 'pi pi-clock text-amber-500'"
|
||||
class="text-sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm">{{ sig.signatario_nome || sig.signatario_tipo }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] ml-2">{{ sig.signatario_tipo }}</span>
|
||||
</div>
|
||||
<span v-if="sig.assinado_em" class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ new Date(sig.assinado_em).toLocaleDateString('pt-BR') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adicionar novos signatarios -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Novos signatários</span>
|
||||
<Button label="Adicionar" icon="pi pi-plus" size="small" text @click="addSignatario" />
|
||||
</div>
|
||||
|
||||
<div v-if="!signatarios.length" class="text-center py-4 text-sm text-[var(--text-color-secondary)]">
|
||||
Clique em "Adicionar" para incluir signatários.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-for="(sig, idx) in signatarios"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[120px_1fr_1fr_auto] gap-2 items-end"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select
|
||||
v-model="sig.tipo"
|
||||
:options="TIPOS_SIGNATARIO"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">Nome <span class="text-red-400">*</span></label>
|
||||
<InputText v-model="sig.nome" placeholder="Nome" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">E-mail <span class="text-red-400">*</span></label>
|
||||
<InputText v-model="sig.email" placeholder="email@..." class="w-full" />
|
||||
</div>
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="removeSignatario(idx)" class="mb-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails cadastrados do paciente -->
|
||||
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">E-mails do paciente</div>
|
||||
<div v-if="patientEmails.length" class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(email, i) in patientEmails"
|
||||
:key="i"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<InputText :modelValue="email" readonly class="w-full !text-xs !bg-transparent" />
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7 flex-shrink-0"
|
||||
v-tooltip.top="'Copiar e usar'"
|
||||
@click="useEmail(email)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-[var(--text-color-secondary)] italic py-1">
|
||||
Nenhum e-mail cadastrado anteriormente foi encontrado.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button label="Cancelar" text @click="close" :disabled="saving" />
|
||||
<Button
|
||||
label="Solicitar assinatura"
|
||||
icon="pi pi-send"
|
||||
:loading="saving"
|
||||
:disabled="!signatarios.length"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentTagsInput.vue
|
||||
| Input de tags livres com chips editaveis e autocomplete.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
suggestions: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Adicionar tag...' },
|
||||
maxTags: { type: Number, default: 20 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const inputValue = ref('')
|
||||
const inputRef = ref(null)
|
||||
const showSuggestions = ref(false)
|
||||
|
||||
const tags = computed({
|
||||
get: () => props.modelValue || [],
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
const q = inputValue.value.toLowerCase().trim()
|
||||
if (!q) return []
|
||||
return props.suggestions
|
||||
.filter(s => s.toLowerCase().includes(q) && !tags.value.includes(s))
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
function addTag(value) {
|
||||
const tag = String(value || '').trim().toLowerCase()
|
||||
if (!tag) return
|
||||
if (tags.value.includes(tag)) return
|
||||
if (tags.value.length >= props.maxTags) return
|
||||
|
||||
tags.value = [...tags.value, tag]
|
||||
inputValue.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
function removeTag(index) {
|
||||
const copy = [...tags.value]
|
||||
copy.splice(index, 1)
|
||||
tags.value = copy
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addTag(inputValue.value)
|
||||
}
|
||||
if (e.key === 'Backspace' && !inputValue.value && tags.value.length) {
|
||||
removeTag(tags.value.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
showSuggestions.value = inputValue.value.trim().length > 0
|
||||
}
|
||||
|
||||
function selectSuggestion(s) {
|
||||
addTag(s)
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 min-h-[2.5rem] px-2.5 py-1.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-overlay)] focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-colors"
|
||||
>
|
||||
<!-- Tags existentes -->
|
||||
<span
|
||||
v-for="(tag, idx) in tags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary"
|
||||
>
|
||||
{{ tag }}
|
||||
<i
|
||||
class="pi pi-times text-[0.55rem] cursor-pointer opacity-60 hover:opacity-100"
|
||||
@click="removeTag(idx)"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<!-- Input -->
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
:placeholder="tags.length ? '' : placeholder"
|
||||
class="!border-0 !shadow-none !ring-0 !p-0 !min-w-[80px] flex-1 text-sm !bg-transparent"
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
@focus="onInput"
|
||||
@blur="setTimeout(() => showSuggestions = false, 150)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown sugestoes -->
|
||||
<div
|
||||
v-if="showSuggestions && filteredSuggestions.length"
|
||||
class="absolute z-50 top-full left-0 right-0 mt-1 py-1 rounded-md border border-[var(--surface-border)] bg-[var(--surface-overlay)] shadow-lg max-h-[200px] overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="s in filteredSuggestions"
|
||||
:key="s"
|
||||
class="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--surface-hover)] transition-colors"
|
||||
@mousedown.prevent="selectSuggestion(s)"
|
||||
>
|
||||
{{ s }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,207 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentTemplateEditor.vue
|
||||
| Editor de template: edicao HTML, insercao de variaveis, preview ao vivo.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
mode: { type: String, default: 'create' } // create | edit
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
|
||||
|
||||
const { TIPOS_TEMPLATE, TEMPLATE_VARIABLES, variablesGrouped, previewHtml } = useDocumentTemplates()
|
||||
|
||||
const activeTab = ref('editor') // editor | preview
|
||||
|
||||
// ── Form reativo synced com modelValue ──────────────────────
|
||||
|
||||
const form = ref({ ...defaultForm(), ...props.modelValue })
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
nome_template: '',
|
||||
tipo: 'outro',
|
||||
descricao: '',
|
||||
corpo_html: '',
|
||||
cabecalho_html: '',
|
||||
rodape_html: '',
|
||||
variaveis: [],
|
||||
logo_url: ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
form.value = { ...defaultForm(), ...val }
|
||||
}, { deep: true })
|
||||
|
||||
watch(form, (val) => {
|
||||
emit('update:modelValue', { ...val })
|
||||
}, { deep: true })
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────
|
||||
|
||||
const renderedPreview = computed(() => previewHtml(form.value.corpo_html))
|
||||
const renderedCabecalho = computed(() => previewHtml(form.value.cabecalho_html || ''))
|
||||
const renderedRodape = computed(() => previewHtml(form.value.rodape_html || ''))
|
||||
|
||||
// ── Inserir variavel no corpo ───────────────────────────────
|
||||
|
||||
const cursorField = ref('corpo_html') // qual campo esta ativo
|
||||
const editorCabecalho = ref(null)
|
||||
const editorCorpo = ref(null)
|
||||
const editorRodape = ref(null)
|
||||
|
||||
function insertVariable(varKey) {
|
||||
const tag = `{{${varKey}}}`
|
||||
const editorMap = {
|
||||
cabecalho_html: editorCabecalho,
|
||||
corpo_html: editorCorpo,
|
||||
rodape_html: editorRodape
|
||||
}
|
||||
const editorRef = editorMap[cursorField.value]
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag)
|
||||
} else {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
|
||||
}
|
||||
|
||||
// Adiciona a variavel na lista se nao estiver
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────
|
||||
|
||||
function onSave() {
|
||||
emit('save', { ...form.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Header: nome e tipo -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_200px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select
|
||||
v-model="form.tipo"
|
||||
:options="TIPOS_TEMPLATE"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição</label>
|
||||
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Editor / Preview -->
|
||||
<div class="flex items-center gap-1 border-b border-[var(--surface-border)]">
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'editor' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = 'editor'"
|
||||
>
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'preview' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = 'preview'"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="activeTab === 'editor'" class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Campos HTML -->
|
||||
<div class="flex-1 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel de variaveis -->
|
||||
<div class="w-full lg:w-[220px] flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">
|
||||
Variáveis
|
||||
</div>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)] mb-3">
|
||||
Clique para inserir no campo ativo
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 max-h-[500px] overflow-y-auto pr-1">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
|
||||
<div class="text-[0.65rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="text-left text-xs px-2 py-1 rounded hover:bg-primary/10 hover:text-primary transition-colors truncate"
|
||||
:title="v.key"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
<span class="font-mono text-[0.65rem] opacity-60">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.65rem] opacity-60">}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
|
||||
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
|
||||
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
|
||||
<div class="min-h-[300px]" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acoes -->
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button label="Cancelar" text @click="emit('cancel')" />
|
||||
<Button :label="mode === 'create' ? 'Criar template' : 'Salvar'" icon="pi pi-check" @click="onSave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,279 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentUploadDialog.vue
|
||||
| Dialog de upload — drag & drop, tipo, categoria, tags, visibilidade.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DocumentTagsInput from './DocumentTagsInput.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' },
|
||||
usedTags: { type: Array, default: () => [] },
|
||||
sessions: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'uploaded'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── State ───────────────────────────────────────────────────
|
||||
|
||||
const file = ref(null)
|
||||
const filePreviewUrl = ref('')
|
||||
const dragging = ref(false)
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
|
||||
const form = reactive({
|
||||
tipo_documento: 'outro',
|
||||
categoria: '',
|
||||
descricao: '',
|
||||
tags: [],
|
||||
agenda_evento_id: null,
|
||||
visibilidade: 'privado',
|
||||
compartilhado_portal: false,
|
||||
compartilhado_supervisor: false
|
||||
})
|
||||
|
||||
const TIPOS = [
|
||||
{ value: 'laudo', label: 'Laudo' },
|
||||
{ value: 'receita', label: 'Receita' },
|
||||
{ value: 'exame', label: 'Exame' },
|
||||
{ value: 'termo_assinado', label: 'Termo assinado' },
|
||||
{ value: 'relatorio_externo', label: 'Relatório externo' },
|
||||
{ value: 'identidade', label: 'Identidade' },
|
||||
{ value: 'convenio', label: 'Convênio' },
|
||||
{ value: 'declaracao', label: 'Declaração' },
|
||||
{ value: 'atestado', label: 'Atestado' },
|
||||
{ value: 'recibo', label: 'Recibo' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
]
|
||||
|
||||
const VISIBILIDADES = [
|
||||
{ value: 'privado', label: 'Privado (só eu)' },
|
||||
{ value: 'compartilhado_supervisor', label: 'Compartilhado com supervisor' },
|
||||
{ value: 'compartilhado_portal', label: 'Visível no portal do paciente' }
|
||||
]
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
file.value = null
|
||||
filePreviewUrl.value = ''
|
||||
formErr.value = ''
|
||||
Object.assign(form, {
|
||||
tipo_documento: 'outro',
|
||||
categoria: '',
|
||||
descricao: '',
|
||||
tags: [],
|
||||
agenda_evento_id: null,
|
||||
visibilidade: 'privado',
|
||||
compartilhado_portal: false,
|
||||
compartilhado_supervisor: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Sync visibilidade ───────────────────────────────────────
|
||||
|
||||
watch(() => form.visibilidade, (v) => {
|
||||
form.compartilhado_portal = v === 'compartilhado_portal'
|
||||
form.compartilhado_supervisor = v === 'compartilhado_supervisor'
|
||||
})
|
||||
|
||||
// ── File handling ───────────────────────────────────────────
|
||||
|
||||
const MAX_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
function onFileSelected(e) {
|
||||
const f = e.target?.files?.[0]
|
||||
if (f) setFile(f)
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
dragging.value = false
|
||||
const f = e.dataTransfer?.files?.[0]
|
||||
if (f) setFile(f)
|
||||
}
|
||||
|
||||
function setFile(f) {
|
||||
if (f.size > MAX_SIZE) {
|
||||
toast.add({ severity: 'warn', summary: 'Arquivo grande', detail: 'Máximo 50 MB.' })
|
||||
return
|
||||
}
|
||||
file.value = f
|
||||
filePreviewUrl.value = f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
|
||||
formErr.value = ''
|
||||
}
|
||||
|
||||
function removeFile() {
|
||||
file.value = null
|
||||
if (filePreviewUrl.value) URL.revokeObjectURL(filePreviewUrl.value)
|
||||
filePreviewUrl.value = ''
|
||||
}
|
||||
|
||||
const fileSizeFormatted = computed(() => {
|
||||
if (!file.value) return ''
|
||||
const b = file.value.size
|
||||
if (b < 1024) return b + ' B'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
||||
return (b / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────
|
||||
|
||||
async function submit() {
|
||||
if (!file.value) { formErr.value = 'Selecione um arquivo.'; return }
|
||||
if (!props.patientId) { formErr.value = 'Paciente não informado.'; return }
|
||||
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
|
||||
try {
|
||||
emit('uploaded', { file: file.value, meta: { ...form } })
|
||||
close()
|
||||
} catch (e) {
|
||||
formErr.value = e?.message || 'Erro ao enviar arquivo.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
class="w-[40rem]"
|
||||
:breakpoints="{ '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-500/10">
|
||||
<i class="pi pi-upload text-blue-500" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Upload de documento</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]" v-if="patientName">{{ patientName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
v-if="!file"
|
||||
class="flex flex-col items-center justify-center gap-3 p-8 rounded-lg border-2 border-dashed transition-colors cursor-pointer"
|
||||
:class="dragging ? 'border-primary bg-primary/5' : 'border-[var(--surface-border)] hover:border-[var(--surface-400)]'"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<i class="pi pi-cloud-upload text-3xl text-[var(--text-color-secondary)]" />
|
||||
<div class="text-sm text-[var(--text-color-secondary)] text-center">
|
||||
<span class="font-medium text-primary">Clique para selecionar</span> ou arraste o arquivo aqui
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] opacity-60">
|
||||
PDF, imagem, Word, Excel — até 50 MB
|
||||
</div>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileSelected" />
|
||||
</div>
|
||||
|
||||
<!-- Arquivo selecionado -->
|
||||
<div v-else class="flex items-center gap-3 p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<img v-if="filePreviewUrl" :src="filePreviewUrl" class="w-12 h-12 rounded object-cover" />
|
||||
<i v-else class="pi pi-file text-2xl text-[var(--text-color-secondary)]" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">{{ fileSizeFormatted }}</div>
|
||||
</div>
|
||||
<Button icon="pi pi-times" text rounded size="small" severity="danger" @click="removeFile" />
|
||||
</div>
|
||||
|
||||
<!-- Campos -->
|
||||
<div class="flex flex-col gap-3.5 mt-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo do documento</label>
|
||||
<Select
|
||||
v-model="form.tipo_documento"
|
||||
:options="TIPOS"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Visibilidade</label>
|
||||
<Select
|
||||
v-model="form.visibilidade"
|
||||
:options="VISIBILIDADES"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" v-if="sessions.length">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Vincular a sessão (opcional)</label>
|
||||
<Select
|
||||
v-model="form.agenda_evento_id"
|
||||
:options="sessions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Nenhuma sessão"
|
||||
showClear
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição (opcional)</label>
|
||||
<Textarea v-model="form.descricao" rows="2" autoResize class="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tags</label>
|
||||
<DocumentTagsInput v-model="form.tags" :suggestions="usedTags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="formErr" class="mt-3 text-sm text-red-500 flex items-center gap-1.5">
|
||||
<i class="pi pi-exclamation-circle text-xs" />
|
||||
{{ formErr }}
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button label="Cancelar" text @click="close" :disabled="saving" />
|
||||
<Button label="Enviar" icon="pi pi-upload" :loading="saving" @click="submit" :disabled="!file" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/composables/useDocumentGenerate.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
loadAllVariables,
|
||||
fillTemplate,
|
||||
buildFullHtml,
|
||||
generatePdfBlob,
|
||||
generateAndDownloadPdf,
|
||||
printDocument as printPdf,
|
||||
saveGeneratedDocument,
|
||||
listGeneratedDocuments
|
||||
} from '@/services/DocumentGenerate.service';
|
||||
import { getTemplate } from '@/services/DocumentTemplates.service';
|
||||
|
||||
// ── Composable ──────────────────────────────────────────────
|
||||
|
||||
export function useDocumentGenerate() {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const generatedDocs = ref([]);
|
||||
|
||||
// Dados carregados para preenchimento
|
||||
const variables = ref({});
|
||||
const selectedTemplate = ref(null);
|
||||
const previewHtml = ref('');
|
||||
|
||||
// ── Carregar variaveis do paciente/sessao ───────────────
|
||||
|
||||
async function loadVariables(patientId, agendaEventoId = null) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
variables.value = await loadAllVariables(patientId, agendaEventoId);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar dados do paciente.';
|
||||
variables.value = {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selecionar template e gerar preview ─────────────────
|
||||
|
||||
async function selectTemplate(templateId) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
selectedTemplate.value = await getTemplate(templateId);
|
||||
updatePreview();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar template.';
|
||||
selectedTemplate.value = null;
|
||||
previewHtml.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Atualizar preview ───────────────────────────────────
|
||||
|
||||
function updatePreview() {
|
||||
if (!selectedTemplate.value) {
|
||||
previewHtml.value = '';
|
||||
return;
|
||||
}
|
||||
previewHtml.value = buildFullHtml(selectedTemplate.value, variables.value);
|
||||
}
|
||||
|
||||
// ── Atualizar variavel individual ───────────────────────
|
||||
|
||||
function setVariable(key, value) {
|
||||
variables.value[key] = value;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ── Gerar PDF (client-side) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera PDF blob, faz download, salva no Storage + banco.
|
||||
*/
|
||||
async function generateAndSave(patientId) {
|
||||
if (!selectedTemplate.value) throw new Error('Nenhum template selecionado.');
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const templateNome = selectedTemplate.value.nome_template || 'documento';
|
||||
|
||||
// Gera PDF blob
|
||||
const blob = await generatePdfBlob(selectedTemplate.value, variables.value);
|
||||
|
||||
// Salva no Storage + banco (generated-docs + documents)
|
||||
const result = await saveGeneratedDocument({
|
||||
templateId: selectedTemplate.value.id,
|
||||
patientId,
|
||||
dadosPreenchidos: { ...variables.value },
|
||||
pdfBlob: blob,
|
||||
templateNome
|
||||
});
|
||||
generatedDocs.value.unshift(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao gerar documento.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera somente o PDF e faz download, sem salvar no banco.
|
||||
*/
|
||||
async function downloadOnly() {
|
||||
if (!selectedTemplate.value) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const templateNome = selectedTemplate.value?.nome_template || 'documento';
|
||||
const filename = `${templateNome.replace(/\s+/g, '_')}_${Date.now()}.pdf`;
|
||||
await generateAndDownloadPdf(selectedTemplate.value, variables.value, filename);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao gerar PDF.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre PDF em nova aba para impressao.
|
||||
*/
|
||||
function printDocument() {
|
||||
if (!selectedTemplate.value) return;
|
||||
printPdf(selectedTemplate.value, variables.value);
|
||||
}
|
||||
|
||||
// ── Carregar historico de documentos gerados ────────────
|
||||
|
||||
async function fetchGeneratedDocs(patientId) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
generatedDocs.value = await listGeneratedDocuments(patientId);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar documentos gerados.';
|
||||
generatedDocs.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset ───────────────────────────────────────────────
|
||||
|
||||
function reset() {
|
||||
selectedTemplate.value = null;
|
||||
variables.value = {};
|
||||
previewHtml.value = '';
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
loading,
|
||||
error,
|
||||
variables,
|
||||
selectedTemplate,
|
||||
previewHtml,
|
||||
generatedDocs,
|
||||
|
||||
// Actions
|
||||
loadVariables,
|
||||
selectTemplate,
|
||||
updatePreview,
|
||||
setVariable,
|
||||
generateAndSave,
|
||||
downloadOnly,
|
||||
printDocument,
|
||||
fetchGeneratedDocs,
|
||||
reset
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/composables/useDocumentTemplates.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
listTemplates,
|
||||
listAllTemplates,
|
||||
getTemplate,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
extractVariablesFromHtml,
|
||||
TEMPLATE_VARIABLES
|
||||
} from '@/services/DocumentTemplates.service';
|
||||
|
||||
// ── Composable ──────────────────────────────────────────────
|
||||
|
||||
export function useDocumentTemplates() {
|
||||
const templates = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const currentTemplate = ref(null);
|
||||
|
||||
// ── Tipos de template (para selects) ────────────────────
|
||||
|
||||
const TIPOS_TEMPLATE = [
|
||||
{ value: 'declaracao_comparecimento', label: 'Declaração de comparecimento' },
|
||||
{ value: 'atestado_psicologico', label: 'Atestado psicológico' },
|
||||
{ value: 'relatorio_acompanhamento', label: 'Relatório de acompanhamento' },
|
||||
{ value: 'recibo_pagamento', label: 'Recibo de pagamento' },
|
||||
{ value: 'termo_consentimento', label: 'Termo de consentimento (TCLE)' },
|
||||
{ value: 'encaminhamento', label: 'Encaminhamento' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
];
|
||||
|
||||
// ── Computed ────────────────────────────────────────────
|
||||
|
||||
const globalTemplates = computed(() =>
|
||||
templates.value.filter(t => t.is_global)
|
||||
);
|
||||
|
||||
const tenantTemplates = computed(() =>
|
||||
templates.value.filter(t => !t.is_global)
|
||||
);
|
||||
|
||||
const activeTemplates = computed(() =>
|
||||
templates.value.filter(t => t.ativo)
|
||||
);
|
||||
|
||||
// ── Variaveis agrupadas (para dropdown no editor) ───────
|
||||
|
||||
const variablesGrouped = computed(() => {
|
||||
const groups = {};
|
||||
for (const v of TEMPLATE_VARIABLES) {
|
||||
if (!groups[v.grupo]) groups[v.grupo] = [];
|
||||
groups[v.grupo].push(v);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// ── Carregar ────────────────────────────────────────────
|
||||
|
||||
async function fetchTemplates(includeInactive = false) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
templates.value = includeInactive
|
||||
? await listAllTemplates()
|
||||
: await listTemplates();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar templates.';
|
||||
templates.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplate(id) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
currentTemplate.value = await getTemplate(id);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar template.';
|
||||
currentTemplate.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────
|
||||
|
||||
async function create(payload) {
|
||||
const created = await createTemplate(payload);
|
||||
templates.value.unshift(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function update(id, payload) {
|
||||
const updated = await updateTemplate(id, payload);
|
||||
const idx = templates.value.findIndex(t => t.id === id);
|
||||
if (idx >= 0) templates.value[idx] = updated;
|
||||
if (currentTemplate.value?.id === id) currentTemplate.value = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await deleteTemplate(id);
|
||||
templates.value = templates.value.filter(t => t.id !== id);
|
||||
}
|
||||
|
||||
async function duplicate(id) {
|
||||
const copy = await duplicateTemplate(id);
|
||||
templates.value.unshift(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ── Extrair variaveis do HTML ───────────────────────────
|
||||
|
||||
function extractVariables(html) {
|
||||
return extractVariablesFromHtml(html);
|
||||
}
|
||||
|
||||
// ── Preview com dados ficticios ─────────────────────────
|
||||
|
||||
const SAMPLE_DATA = {
|
||||
paciente_nome: 'Maria Silva Santos',
|
||||
paciente_nome_social: 'Maria Santos',
|
||||
paciente_cpf: '123.456.789-00',
|
||||
paciente_data_nascimento: '15/03/1990',
|
||||
paciente_telefone: '(16) 99999-0000',
|
||||
paciente_email: 'maria@exemplo.com',
|
||||
paciente_endereco: 'Rua das Flores, 123, Centro, São Carlos/SP',
|
||||
data_sessao: '28/03/2026',
|
||||
hora_inicio: '14:00',
|
||||
hora_fim: '14:50',
|
||||
modalidade: 'Presencial',
|
||||
terapeuta_nome: 'Dr. João Oliveira',
|
||||
terapeuta_crp: '06/12345',
|
||||
terapeuta_email: 'joao@clinica.com',
|
||||
terapeuta_telefone: '(16) 3333-0000',
|
||||
clinica_nome: 'Clínica Exemplo',
|
||||
clinica_endereco: 'Av. São Carlos, 500, Centro, São Carlos/SP',
|
||||
clinica_telefone: '(16) 3333-1111',
|
||||
clinica_cnpj: '12.345.678/0001-00',
|
||||
valor: 'R$ 200,00',
|
||||
valor_extenso: 'duzentos reais',
|
||||
forma_pagamento: 'PIX',
|
||||
data_atual: new Date().toLocaleDateString('pt-BR'),
|
||||
data_atual_extenso: formatDateExtenso(new Date()),
|
||||
cidade_estado: 'São Carlos/SP'
|
||||
};
|
||||
|
||||
function formatDateExtenso(date) {
|
||||
const meses = [
|
||||
'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho',
|
||||
'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'
|
||||
];
|
||||
return `${date.getDate()} de ${meses[date.getMonth()]} de ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
function previewHtml(html) {
|
||||
return String(html || '').replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return SAMPLE_DATA[key] !== undefined
|
||||
? `<span style="background:#fef3c7;padding:1px 4px;border-radius:3px;">${SAMPLE_DATA[key]}</span>`
|
||||
: `<span style="background:#fee2e2;padding:1px 4px;border-radius:3px;">${match}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
templates,
|
||||
loading,
|
||||
error,
|
||||
currentTemplate,
|
||||
|
||||
// Constants
|
||||
TIPOS_TEMPLATE,
|
||||
TEMPLATE_VARIABLES,
|
||||
SAMPLE_DATA,
|
||||
|
||||
// Computed
|
||||
globalTemplates,
|
||||
tenantTemplates,
|
||||
activeTemplates,
|
||||
variablesGrouped,
|
||||
|
||||
// Actions
|
||||
fetchTemplates,
|
||||
fetchTemplate,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
duplicate,
|
||||
extractVariables,
|
||||
previewHtml
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/composables/useDocuments.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import {
|
||||
listDocuments,
|
||||
listAllDocuments,
|
||||
uploadDocument,
|
||||
updateDocument,
|
||||
softDeleteDocument,
|
||||
restoreDocument,
|
||||
getDownloadUrl,
|
||||
getUsedTags
|
||||
} from '@/services/Documents.service';
|
||||
import { logAccess } from '@/services/DocumentAuditLog.service';
|
||||
|
||||
// ── Composable ──────────────────────────────────────────────
|
||||
|
||||
export function useDocuments(patientId = null) {
|
||||
const documents = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const usedTags = ref([]);
|
||||
|
||||
// Filtros reativos
|
||||
const filters = ref({
|
||||
tipo_documento: null,
|
||||
categoria: null,
|
||||
tag: null,
|
||||
search: ''
|
||||
});
|
||||
|
||||
// ── Computed: stats rapidos ─────────────────────────────
|
||||
|
||||
const stats = computed(() => {
|
||||
const docs = documents.value;
|
||||
const total = docs.length;
|
||||
const porTipo = {};
|
||||
const pendentesRevisao = docs.filter(d => d.status_revisao === 'pendente').length;
|
||||
|
||||
for (const d of docs) {
|
||||
const tipo = d.tipo_documento || 'outro';
|
||||
porTipo[tipo] = (porTipo[tipo] || 0) + 1;
|
||||
}
|
||||
|
||||
const tamanhoTotal = docs.reduce((sum, d) => sum + (d.tamanho_bytes || 0), 0);
|
||||
|
||||
return { total, porTipo, pendentesRevisao, tamanhoTotal };
|
||||
});
|
||||
|
||||
// ── Tipos de documento (para filtros) ───────────────────
|
||||
|
||||
const TIPOS_DOCUMENTO = [
|
||||
{ value: 'laudo', label: 'Laudo' },
|
||||
{ value: 'receita', label: 'Receita' },
|
||||
{ value: 'exame', label: 'Exame' },
|
||||
{ value: 'termo_assinado', label: 'Termo assinado' },
|
||||
{ value: 'relatorio_externo', label: 'Relatório externo' },
|
||||
{ value: 'identidade', label: 'Identidade' },
|
||||
{ value: 'convenio', label: 'Convênio' },
|
||||
{ value: 'declaracao', label: 'Declaração' },
|
||||
{ value: 'atestado', label: 'Atestado' },
|
||||
{ value: 'recibo', label: 'Recibo' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
];
|
||||
|
||||
// ── Carregar documentos ─────────────────────────────────
|
||||
|
||||
async function fetchDocuments() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const activeFilters = {};
|
||||
if (filters.value.tipo_documento) activeFilters.tipo_documento = filters.value.tipo_documento;
|
||||
if (filters.value.categoria) activeFilters.categoria = filters.value.categoria;
|
||||
if (filters.value.tag) activeFilters.tag = filters.value.tag;
|
||||
if (filters.value.search) activeFilters.search = filters.value.search;
|
||||
|
||||
const pid = typeof patientId === 'function' ? patientId() : patientId;
|
||||
if (pid) {
|
||||
documents.value = await listDocuments(pid, activeFilters);
|
||||
} else {
|
||||
documents.value = await listAllDocuments(activeFilters);
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar documentos.';
|
||||
documents.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────
|
||||
|
||||
async function upload(file, targetPatientId, meta = {}) {
|
||||
const pid = targetPatientId || (typeof patientId === 'function' ? patientId() : patientId);
|
||||
if (!pid) throw new Error('Paciente não informado.');
|
||||
|
||||
const doc = await uploadDocument(file, pid, meta);
|
||||
documents.value.unshift(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────
|
||||
|
||||
async function update(id, payload) {
|
||||
const updated = await updateDocument(id, payload);
|
||||
const idx = documents.value.findIndex(d => d.id === id);
|
||||
if (idx >= 0) documents.value[idx] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Soft delete ─────────────────────────────────────────
|
||||
|
||||
async function remove(id) {
|
||||
await softDeleteDocument(id);
|
||||
documents.value = documents.value.filter(d => d.id !== id);
|
||||
}
|
||||
|
||||
// ── Restore ─────────────────────────────────────────────
|
||||
|
||||
async function restore(id) {
|
||||
await restoreDocument(id);
|
||||
await fetchDocuments();
|
||||
}
|
||||
|
||||
// ── Download com auditoria ──────────────────────────────
|
||||
|
||||
async function download(doc) {
|
||||
const bucket = doc.storage_bucket || undefined;
|
||||
const url = await getDownloadUrl(doc.bucket_path, 60, bucket);
|
||||
logAccess(doc.id, 'baixou');
|
||||
|
||||
// Abrir download
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.nome_original || 'arquivo';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
// ── Preview com auditoria ───────────────────────────────
|
||||
|
||||
async function getPreviewUrl(doc) {
|
||||
const bucket = doc.storage_bucket || undefined;
|
||||
const url = await getDownloadUrl(doc.bucket_path, 300, bucket);
|
||||
logAccess(doc.id, 'visualizou');
|
||||
return url;
|
||||
}
|
||||
|
||||
// ── Tags ────────────────────────────────────────────────
|
||||
|
||||
async function fetchUsedTags() {
|
||||
try {
|
||||
usedTags.value = await getUsedTags();
|
||||
} catch {
|
||||
usedTags.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Limpar filtros ──────────────────────────────────────
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = { tipo_documento: null, categoria: null, tag: null, search: '' };
|
||||
}
|
||||
|
||||
// ── Helper: formatar tamanho ────────────────────────────
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// ── Helper: icone por mime type ─────────────────────────
|
||||
|
||||
function mimeIcon(mimeType) {
|
||||
const m = String(mimeType || '');
|
||||
if (m.startsWith('image/')) return 'pi pi-image';
|
||||
if (m === 'application/pdf') return 'pi pi-file-pdf';
|
||||
if (m.includes('word') || m.includes('document')) return 'pi pi-file-word';
|
||||
if (m.includes('excel') || m.includes('sheet')) return 'pi pi-file-excel';
|
||||
if (m.startsWith('text/')) return 'pi pi-file';
|
||||
return 'pi pi-file';
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
usedTags,
|
||||
|
||||
// Computed
|
||||
stats,
|
||||
TIPOS_DOCUMENTO,
|
||||
|
||||
// Actions
|
||||
fetchDocuments,
|
||||
upload,
|
||||
update,
|
||||
remove,
|
||||
restore,
|
||||
download,
|
||||
getPreviewUrl,
|
||||
fetchUsedTags,
|
||||
clearFilters,
|
||||
|
||||
// Helpers
|
||||
formatSize,
|
||||
mimeIcon
|
||||
};
|
||||
}
|
||||
@@ -771,7 +771,7 @@ function isRecent(row) {
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
<Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.top="'Descontos'" @click="router.push('/configuracoes/descontos')" />
|
||||
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
|
||||
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" />
|
||||
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" @go-complete="goCreateFull" />
|
||||
<PatientCadastroDialog v-model="cadastroFullDialog" :patient-id="editPatientId" @created="onPatientCreated" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/cadastro/PatientsCadastroPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, sanitizeDigits, toISODate, generateCPF } from '@/utils/validators'
|
||||
|
||||
const props = defineProps({
|
||||
dialogMode: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null }
|
||||
})
|
||||
const emit = defineEmits(['cancel', 'created'])
|
||||
|
||||
const { canSee } = useRoleGuard()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ── Tenant helpers ────────────────────────────────────────
|
||||
async function getCurrentTenantId () {
|
||||
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
}
|
||||
|
||||
async function getCurrentMemberId (tenantId) {
|
||||
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')
|
||||
.eq('tenant_id', tenantId).eq('user_id', uid).eq('status', 'active').single()
|
||||
if (error) throw error
|
||||
if (!data?.id) throw new Error('Responsible member not found')
|
||||
return data.id
|
||||
}
|
||||
|
||||
// ── Accordion ─────────────────────────────────────────────
|
||||
const activeValue = ref('0')
|
||||
const panelHeaderRefs = ref([])
|
||||
|
||||
function setPanelHeaderRef (el, idx) { if (!el) return; panelHeaderRefs.value[idx] = el }
|
||||
|
||||
async function openPanel (i) {
|
||||
activeValue.value = String(i)
|
||||
await nextTick()
|
||||
const headerRef = panelHeaderRefs.value?.[i]
|
||||
const el = headerRef?.$el ?? headerRef
|
||||
if (!el) return
|
||||
const scrollContainer = el.closest('.l2-main') || document.querySelector('.l2-main')
|
||||
if (scrollContainer) {
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const elRect = el.getBoundingClientRect()
|
||||
const offset = elRect.top - containerRect.top + scrollContainer.scrollTop - 16
|
||||
scrollContainer.scrollTo({ top: Math.max(0, offset), behavior: 'smooth' })
|
||||
} else if (typeof el.scrollIntoView === 'function') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nav items ─────────────────────────────────────────────
|
||||
const navItems = [
|
||||
{ value: '0', label: 'Informações pessoais', icon: 'pi pi-user' },
|
||||
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
|
||||
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-briefcase' },
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
|
||||
{ value: '4', label: 'Anotações internas', icon: 'pi pi-lock' },
|
||||
]
|
||||
|
||||
const navPopover = ref(null)
|
||||
function toggleNav (event) { navPopover.value?.toggle(event) }
|
||||
function selectNav (item) { openPanel(Number(item.value)); navPopover.value?.hide() }
|
||||
const selectedNav = computed(() => navItems.find(i => i.value === activeValue.value) || null)
|
||||
|
||||
// Responsivo < 1200px
|
||||
const isCompact = ref(false)
|
||||
let mql = null
|
||||
let mqlHandler = null
|
||||
|
||||
function syncCompact () { isCompact.value = !!mql?.matches }
|
||||
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia('(max-width: 1199px)')
|
||||
syncCompact()
|
||||
mqlHandler = () => syncCompact()
|
||||
if (mql.addEventListener) mql.addEventListener('change', mqlHandler)
|
||||
else mql.addListener(mqlHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!mql || !mqlHandler) return
|
||||
if (mql.removeEventListener) mql.removeEventListener('change', mqlHandler)
|
||||
else mql.removeListener(mqlHandler)
|
||||
})
|
||||
|
||||
// ── Route helpers ─────────────────────────────────────────
|
||||
const patientId = computed(() =>
|
||||
props.dialogMode
|
||||
? (props.patientId || null)
|
||||
: (String(route.params?.id || '').trim() || null)
|
||||
)
|
||||
const isEdit = computed(() => !!patientId.value)
|
||||
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
|
||||
return seg === 'therapist' ? 'therapist' : 'admin'
|
||||
}
|
||||
|
||||
function getPatientsRoutes () {
|
||||
const area = getAreaKey()
|
||||
if (area === 'therapist') return {
|
||||
listName: 'therapist-patients',
|
||||
editName: 'therapist-patients-edit',
|
||||
listPath: '/therapist/patients',
|
||||
editPath: (id) => `/therapist/patients/cadastro/${id}`
|
||||
}
|
||||
return {
|
||||
listName: 'admin-pacientes',
|
||||
editName: 'admin-pacientes-cadastro-edit',
|
||||
listPath: '/admin/pacientes',
|
||||
editPath: (id) => `/admin/pacientes/cadastro/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
async function safePush (toNameObj, fallbackPath) {
|
||||
try { const r = router.resolve(toNameObj); if (r?.matched?.length) return router.push(toNameObj) } catch (_) {}
|
||||
return router.push(fallbackPath)
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
if (props.dialogMode) { emit('cancel'); return }
|
||||
const { listName, listPath } = getPatientsRoutes()
|
||||
if (window.history.length > 1) router.back()
|
||||
else safePush({ name: listName }, listPath)
|
||||
}
|
||||
|
||||
// ── Avatar ────────────────────────────────────────────────
|
||||
const avatarFile = ref(null)
|
||||
const avatarPreviewUrl = ref('')
|
||||
const avatarUploading = ref(false)
|
||||
const AVATAR_BUCKET = 'avatars'
|
||||
|
||||
function isImageFile (file) { return !!file && typeof file.type === 'string' && file.type.startsWith('image/') }
|
||||
function safeExtFromFile (file) { const name = String(file?.name || ''); const ext = name.includes('.') ? name.split('.').pop() : ''; return String(ext || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png' }
|
||||
function revokePreview () { if (avatarPreviewUrl.value?.startsWith('blob:')) { try { URL.revokeObjectURL(avatarPreviewUrl.value) } catch (_) {} } avatarPreviewUrl.value = '' }
|
||||
|
||||
function onAvatarPicked (ev) {
|
||||
const file = ev?.target?.files?.[0] || null
|
||||
avatarFile.value = null; revokePreview()
|
||||
if (!file) return
|
||||
if (!isImageFile(file)) { toast.add({ severity: 'warn', summary: 'Avatar', detail: 'Selecione um arquivo de imagem (PNG/JPG/WebP).', life: 3000 }); return }
|
||||
avatarFile.value = file
|
||||
avatarPreviewUrl.value = URL.createObjectURL(file)
|
||||
toast.add({ severity: 'info', summary: 'Avatar', detail: 'Preview carregado. Clique em "Salvar" para enviar.', life: 2500 })
|
||||
}
|
||||
|
||||
async function getReadableAvatarUrl (path) {
|
||||
try { const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path); if (pub?.publicUrl) return pub.publicUrl } catch (_) {}
|
||||
const { data, error } = await supabase.storage.from(AVATAR_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7)
|
||||
if (error) throw error
|
||||
if (!data?.signedUrl) throw new Error('Não consegui gerar signed URL do avatar.')
|
||||
return data.signedUrl
|
||||
}
|
||||
|
||||
async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
|
||||
if (!ownerId) throw new Error('ownerId ausente.')
|
||||
if (!patientId) throw new Error('patientId ausente.')
|
||||
if (!file) throw new Error('Arquivo de avatar ausente.')
|
||||
if (!isImageFile(file)) throw new Error('Selecione um arquivo de imagem (PNG/JPG/WebP).')
|
||||
if (file.size > 3 * 1024 * 1024) throw new Error('Imagem muito grande. Use até 3MB.')
|
||||
const ext = safeExtFromFile(file)
|
||||
const path = `owners/${ownerId}/patients/${patientId}/avatar.${ext}`
|
||||
const { error: upErr } = await supabase.storage.from(AVATAR_BUCKET).upload(path, file, { upsert: true, cacheControl: '3600', contentType: file.type || 'image/*' })
|
||||
if (upErr) throw upErr
|
||||
return { publicUrl: await getReadableAvatarUrl(path), path }
|
||||
}
|
||||
|
||||
async function maybeUploadAvatar (ownerId, id) {
|
||||
if (!avatarFile.value) return null
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const { publicUrl } = await uploadAvatarToStorage({ ownerId, patientId: id, file: avatarFile.value })
|
||||
form.value.avatar_url = publicUrl; avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = publicUrl
|
||||
await updatePatient(id, { avatar_url: publicUrl })
|
||||
return publicUrl
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Avatar', detail: e?.message || 'Falha ao enviar avatar.', life: 4500 }); return null
|
||||
} finally { avatarUploading.value = false }
|
||||
}
|
||||
|
||||
// ── Form state ────────────────────────────────────────────
|
||||
function resetForm () {
|
||||
return {
|
||||
nome_completo: '', telefone: '', email_principal: '', email_alternativo: '', telefone_alternativo: '',
|
||||
data_nascimento: '', genero: '', estado_civil: '', cpf: '', rg: '', naturalidade: '',
|
||||
observacoes: '', onde_nos_conheceu: '', encaminhado_por: '',
|
||||
cep: '', pais: 'Brasil', cidade: '', estado: 'SP', endereco: '', numero: '', bairro: '', complemento: '',
|
||||
escolaridade: '', profissao: '', nome_parente: '', grau_parentesco: '', telefone_parente: '',
|
||||
nome_responsavel: '', cpf_responsavel: '', telefone_responsavel: '', observacao_responsavel: '',
|
||||
cobranca_no_responsavel: false, notas_internas: '', avatar_url: ''
|
||||
}
|
||||
}
|
||||
const form = ref(resetForm())
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function parseDDMMYYYY (s) {
|
||||
const str = String(s || '').trim(); const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(str); if (!m) return null
|
||||
const dd = Number(m[1]), mm = Number(m[2]), yyyy = Number(m[3]); const dt = new Date(yyyy, mm - 1, dd)
|
||||
if (Number.isNaN(dt.getTime())) return null
|
||||
if (dt.getFullYear() !== yyyy || dt.getMonth() !== mm - 1 || dt.getDate() !== dd) return null
|
||||
return dt
|
||||
}
|
||||
function isoToDDMMYYYY (value) {
|
||||
if (!value) return ''; const s = String(value).trim()
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return s
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); if (m) return `${m[3]}-${m[2]}-${m[1]}`
|
||||
const d = new Date(s); if (Number.isNaN(d.getTime())) return ''
|
||||
return `${String(d.getDate()).padStart(2,'0')}-${String(d.getMonth()+1).padStart(2,'0')}-${d.getFullYear()}`
|
||||
}
|
||||
|
||||
const ageLabel = computed(() => {
|
||||
const dt = parseDDMMYYYY(form.value?.data_nascimento); if (!dt) return '—'
|
||||
const now = new Date(); let age = now.getFullYear() - dt.getFullYear()
|
||||
const mm = now.getMonth() - dt.getMonth()
|
||||
if (mm < 0 || (mm === 0 && now.getDate() < dt.getDate())) age--
|
||||
if (age < 0 || age > 130) return '—'
|
||||
return `${age} anos`
|
||||
})
|
||||
|
||||
// ── DB map ────────────────────────────────────────────────
|
||||
function mapDbToForm (p) {
|
||||
return { ...resetForm(), nome_completo: p.nome_completo ?? '', telefone: fmtPhone(p.telefone ?? ''), email_principal: p.email_principal ?? '', email_alternativo: p.email_alternativo ?? '', telefone_alternativo: fmtPhone(p.telefone_alternativo ?? ''), data_nascimento: p.data_nascimento ? isoToDDMMYYYY(p.data_nascimento) : '', genero: p.genero ?? '', estado_civil: p.estado_civil ?? '', cpf: fmtCPF(p.cpf ?? ''), rg: fmtRG(p.rg ?? ''), naturalidade: p.naturalidade ?? '', observacoes: p.observacoes ?? '', onde_nos_conheceu: p.onde_nos_conheceu ?? '', encaminhado_por: p.encaminhado_por ?? '', cep: p.cep ?? '', pais: p.pais ?? 'Brasil', cidade: p.cidade ?? '', estado: p.estado ?? 'SP', endereco: p.endereco ?? '', numero: p.numero ?? '', bairro: p.bairro ?? '', complemento: p.complemento ?? '', escolaridade: p.escolaridade ?? '', profissao: p.profissao ?? '', nome_parente: p.nome_parente ?? '', grau_parentesco: p.grau_parentesco ?? '', telefone_parente: fmtPhone(p.telefone_parente ?? ''), nome_responsavel: p.nome_responsavel ?? '', cpf_responsavel: fmtCPF(p.cpf_responsavel ?? ''), telefone_responsavel: fmtPhone(p.telefone_responsavel ?? ''), observacao_responsavel: p.observacao_responsavel ?? '', cobranca_no_responsavel: !!p.cobranca_no_responsavel, notas_internas: p.notas_internas ?? '', avatar_url: p.avatar_url ?? '' }
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
async function getOwnerId () {
|
||||
const { data, error } = await supabase.auth.getUser(); if (error) throw error
|
||||
const uid = data?.user?.id; if (!uid) throw new Error('Sessão inválida (auth.getUser).'); return uid
|
||||
}
|
||||
|
||||
// ── Sanitize ──────────────────────────────────────────────
|
||||
const PACIENTES_COLUNAS_PERMITIDAS = new Set(['owner_id','tenant_id','responsible_member_id','nome_completo','telefone','email_principal','email_alternativo','telefone_alternativo','data_nascimento','genero','estado_civil','cpf','rg','naturalidade','observacoes','onde_nos_conheceu','encaminhado_por','pais','cep','cidade','estado','endereco','numero','bairro','complemento','escolaridade','profissao','nome_parente','grau_parentesco','telefone_parente','nome_responsavel','cpf_responsavel','telefone_responsavel','observacao_responsavel','cobranca_no_responsavel','notas_internas','avatar_url'])
|
||||
|
||||
function sanitizePayload (raw, ownerId) {
|
||||
const payload = { owner_id: ownerId, nome_completo: raw.nome_completo, telefone: raw.telefone, email_principal: raw.email_principal, email_alternativo: raw.email_alternativo || null, telefone_alternativo: raw.telefone_alternativo || null, data_nascimento: raw.data_nascimento || null, genero: raw.genero || null, estado_civil: raw.estado_civil || null, cpf: raw.cpf || null, rg: raw.rg || null, naturalidade: raw.naturalidade || null, observacoes: raw.observacoes || null, onde_nos_conheceu: raw.onde_nos_conheceu || null, encaminhado_por: raw.encaminhado_por || null, cep: raw.cep || null, pais: raw.pais || null, cidade: raw.cidade || null, estado: raw.estado || null, endereco: raw.endereco || null, numero: raw.numero || null, bairro: raw.bairro || null, complemento: raw.complemento || null, escolaridade: raw.escolaridade || null, profissao: raw.profissao || null, nome_parente: raw.nome_parente || null, grau_parentesco: raw.grau_parentesco || null, telefone_parente: raw.telefone_parente || null, nome_responsavel: raw.nome_responsavel || null, cpf_responsavel: raw.cpf_responsavel || null, telefone_responsavel: raw.telefone_responsavel || null, observacao_responsavel: raw.observacao_responsavel || null, cobranca_no_responsavel: !!raw.cobranca_no_responsavel, notas_internas: raw.notas_internas || null, avatar_url: raw.avatar_url || null }
|
||||
Object.keys(payload).forEach(k => { if (payload[k] === '') payload[k] = null; if (typeof payload[k] === 'string') { const t = payload[k].trim(); payload[k] = t === '' ? null : t } })
|
||||
payload.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
|
||||
payload.rg = payload.rg ? digitsOnly(payload.rg) : null
|
||||
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : null
|
||||
payload.telefone = payload.telefone ? digitsOnly(payload.telefone) : null
|
||||
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : null
|
||||
payload.telefone_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
|
||||
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : null
|
||||
payload.data_nascimento = payload.data_nascimento ? (toISODate(payload.data_nascimento) || null) : null
|
||||
const filtrado = {}; Object.keys(payload).forEach(k => { if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k] })
|
||||
return filtrado
|
||||
}
|
||||
|
||||
// ── DB calls ──────────────────────────────────────────────
|
||||
async function listGroups () {
|
||||
const probe = await supabase.from('patient_groups').select('*').limit(1); if (probe.error) throw probe.error
|
||||
const row = probe.data?.[0] || {}; const hasPT = ('nome' in row) || ('cor' in row); const hasEN = ('name' in row) || ('color' in row)
|
||||
if (hasPT) { const { data, error } = await supabase.from('patient_groups').select('id,nome,descricao,cor,is_system,is_active').eq('is_active', true).order('nome', { ascending: true }); if (error) throw error; return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor })) }
|
||||
if (hasEN) { const { data, error } = await supabase.from('patient_groups').select('id,name,description,color,is_system,is_active').eq('is_active', true).order('name', { ascending: true }); if (error) throw error; return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color })) }
|
||||
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true }); if (error) throw error; return data || []
|
||||
}
|
||||
|
||||
async function listTags () {
|
||||
const probe = await supabase.from('patient_tags').select('*').limit(1); if (probe.error) throw probe.error
|
||||
const row = probe.data?.[0] || {}; const hasEN = ('name' in row) || ('color' in row); const hasPT = ('nome' in row) || ('cor' in row)
|
||||
if (hasEN) { const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true }); if (error) throw error; return data || [] }
|
||||
if (hasPT) { const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true }); if (error) throw error; return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor })) }
|
||||
const { data, error } = await supabase.from('patient_tags').select('*').order('id', { ascending: true }); if (error) throw error; return (data || []).map(t => ({ ...t, name: t.name ?? t.nome ?? '', color: t.color ?? t.cor ?? null }))
|
||||
}
|
||||
|
||||
async function getPatientById (id) { const { data, error } = await supabase.from('patients').select('*').eq('id', id).single(); if (error) throw error; return data }
|
||||
|
||||
async function getPatientRelations (id) {
|
||||
const { data: g, error: ge } = await supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', id); if (ge) throw ge
|
||||
const { data: t, error: te } = await supabase.from('patient_patient_tag').select('tag_id').eq('patient_id', id); if (te) throw te
|
||||
return { groupIds: (g || []).map(x => x.patient_group_id).filter(Boolean), tagIds: (t || []).map(x => x.tag_id).filter(Boolean) }
|
||||
}
|
||||
|
||||
async function createPatient (payload) { const { data, error } = await supabase.from('patients').insert(payload).select('id').single(); if (error) throw error; return data }
|
||||
async function updatePatient (id, payload) { const { error } = await supabase.from('patients').update({ ...payload, updated_at: new Date().toISOString() }).eq('id', id); if (error) throw error }
|
||||
|
||||
// ── Relations ─────────────────────────────────────────────
|
||||
const groups = ref([])
|
||||
const tags = ref([])
|
||||
const grupoIdSelecionado = ref(null)
|
||||
const tagIdsSelecionadas = ref([])
|
||||
|
||||
async function replacePatientGroups (patient_id, groupId) {
|
||||
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id); if (delErr) throw delErr
|
||||
if (!groupId) return
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId }); if (insErr) throw insErr
|
||||
}
|
||||
|
||||
async function replacePatientTags (patient_id, tagIds) {
|
||||
const ownerId = await getOwnerId()
|
||||
const { error: delErr } = await supabase.from('patient_patient_tag').delete().eq('patient_id', patient_id).eq('owner_id', ownerId); if (delErr) throw delErr
|
||||
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean))); if (!clean.length) return
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
|
||||
const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows); if (insErr) throw insErr
|
||||
}
|
||||
|
||||
// ── CEP ───────────────────────────────────────────────────
|
||||
async function fetchCep (cepRaw) {
|
||||
const cep = digitsOnly(cepRaw); if (cep.length !== 8) return null
|
||||
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
|
||||
const data = await res.json(); if (!data || data.erro) return null; return data
|
||||
}
|
||||
async function onCepBlur () {
|
||||
try {
|
||||
const d = await fetchCep(form.value.cep); if (!d) return
|
||||
form.value.cidade = d.localidade || form.value.cidade; form.value.estado = d.uf || form.value.estado
|
||||
form.value.bairro = d.bairro || form.value.bairro; form.value.endereco = d.logradouro || form.value.endereco
|
||||
if (!form.value.complemento) form.value.complemento = d.complemento || ''
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── UI state ──────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// ── Fetch ─────────────────────────────────────────────────
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
|
||||
if (gRes.status === 'fulfilled') groups.value = gRes.value || []
|
||||
else { groups.value = []; toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 }) }
|
||||
if (tRes.status === 'fulfilled') tags.value = tRes.value || []
|
||||
else { tags.value = []; toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 }) }
|
||||
if (isEdit.value) {
|
||||
const p = await getPatientById(patientId.value)
|
||||
form.value = mapDbToForm(p)
|
||||
avatarPreviewUrl.value = form.value.avatar_url || ''
|
||||
const rel = await getPatientRelations(patientId.value)
|
||||
grupoIdSelecionado.value = rel.groupIds?.[0] || null
|
||||
tagIdsSelecionadas.value = rel.tagIds || []
|
||||
} else {
|
||||
grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []; avatarFile.value = null; revokePreview()
|
||||
}
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar cadastro', life: 3500 })
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(patientId, fetchAll, { immediate: true })
|
||||
|
||||
// ── Tenant resolve ────────────────────────────────────────
|
||||
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 storeTid = await getCurrentTenantId()
|
||||
if (storeTid) { try { const mid = await getCurrentMemberId(storeTid); return { tenantId: storeTid, memberId: mid } } catch (_) {} }
|
||||
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')
|
||||
return { tenantId: data.tenant_id, memberId: data.id }
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────
|
||||
async function onSubmit () {
|
||||
try {
|
||||
saving.value = true
|
||||
const ownerId = await getOwnerId()
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
|
||||
const nome = String(form.value?.nome_completo || '').trim()
|
||||
if (!nome) { toast.add({ severity: 'warn', summary: 'Nome obrigatório', detail: 'Preencha "Nome completo" para salvar o paciente.', life: 3500 }); await openPanel(0); return }
|
||||
if (isEdit.value) {
|
||||
await updatePatient(patientId.value, payload)
|
||||
await maybeUploadAvatar(ownerId, patientId.value)
|
||||
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
|
||||
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
|
||||
if (props.dialogMode) { emit('created', { id: patientId.value }); return }
|
||||
return
|
||||
}
|
||||
const created = await createPatient(payload)
|
||||
await maybeUploadAvatar(ownerId, created.id)
|
||||
await replacePatientGroups(created.id, grupoIdSelecionado.value)
|
||||
await replacePatientTags(created.id, tagIdsSelecionadas.value)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
|
||||
if (props.dialogMode) { emit('created', created); return }
|
||||
form.value = resetForm(); grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []
|
||||
avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = ''
|
||||
await openPanel(0)
|
||||
} catch (e) {
|
||||
console.error(e); toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────
|
||||
function confirmDelete () {
|
||||
if (!isEdit.value) return
|
||||
confirm.require({ header: 'Excluir paciente', message: 'Tem certeza que deseja excluir este paciente? Essa ação não pode ser desfeita.', icon: 'pi pi-exclamation-triangle', acceptLabel: 'Excluir', rejectLabel: 'Cancelar', acceptClass: 'p-button-danger', accept: async () => doDelete() })
|
||||
}
|
||||
|
||||
async function doDelete () {
|
||||
if (!isEdit.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const pid = patientId.value
|
||||
const { error: e1 } = await supabase.from('patient_group_patient').delete().eq('patient_id', pid); if (e1) throw e1
|
||||
const { error: e2 } = await supabase.from('patient_patient_tag').delete().eq('patient_id', pid); if (e2) throw e2
|
||||
const { error: e3 } = await supabase.from('patients').delete().eq('id', pid); if (e3) throw e3
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 })
|
||||
if (props.dialogMode) { emit('created', null); return }
|
||||
goBack()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao excluir paciente', life: 4000 })
|
||||
} finally { deleting.value = false }
|
||||
}
|
||||
|
||||
// ── Fake fill ─────────────────────────────────────────────
|
||||
function randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
|
||||
function pick (arr) { return arr[randInt(0, arr.length - 1)] }
|
||||
function maybe (p = 0.5) { return Math.random() < p }
|
||||
function pad2 (n) { return String(n).padStart(2, '0') }
|
||||
function randomDateDDMMYYYY (minAge = 6, maxAge = 75) { const now = new Date(); const age = randInt(minAge, maxAge); return `${pad2(randInt(1,28))}-${pad2(randInt(1,12))}-${now.getFullYear() - age}` }
|
||||
function randomPhoneBR () { return `+55 (${randInt(11,99)}) ${maybe(0.8)?'9':''}${randInt(1000,9999)}-${randInt(1000,9999)}` }
|
||||
function randomCEP () { return `${randInt(10000,99999)}-${randInt(100,999)}` }
|
||||
function randomEmailFromName (name) { return `${String(name||'paciente').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,'.').replace(/(^\.)|(\.$)/g,'')}.${randInt(10,999)}@email.com` }
|
||||
|
||||
function fillRandomPatient () {
|
||||
const first = ['Ana','Bruno','Carla','Daniel','Eduarda','Felipe','Giovana','Henrique','Isabela','João','Larissa','Marcos','Nathalia','Otávio','Paula','Rafael','Sabrina','Thiago','Vanessa','Yasmin']
|
||||
const last = ['Silva','Santos','Oliveira','Souza','Pereira','Lima','Ferreira','Almeida','Costa','Gomes','Ribeiro','Carvalho','Martins','Araújo','Barbosa']
|
||||
const cities = ['São Carlos','Ribeirão Preto','Campinas','São Paulo','Araraquara','Bauru','Sorocaba','Santos']
|
||||
const nomeCompleto = `${pick(first)} ${pick(last)} ${pick(last)}`
|
||||
form.value = { ...resetForm(), nome_completo: nomeCompleto, telefone: randomPhoneBR(), email_principal: randomEmailFromName(nomeCompleto), email_alternativo: `alt.${randInt(10,999)}@email.com`, telefone_alternativo: randomPhoneBR(), data_nascimento: randomDateDDMMYYYY(6, 78), genero: pick(['Feminino','Masculino','Não-binário','Prefere não informar','Outro']), estado_civil: pick(['Solteiro(a)','Casado(a)','União estável','Divorciado(a)','Viúvo(a)']), cpf: fmtCPF(generateCPF()), rg: fmtRG(String(randInt(10000000,999999999))), naturalidade: pick(cities), observacoes: 'Paciente relata ansiedade e sobrecarga emocional.', onde_nos_conheceu: pick(['Instagram','Google','Indicação','Site','Threads','Outro']), encaminhado_por: `${pick(first)} ${pick(last)}`, cep: randomCEP(), pais: 'Brasil', cidade: pick(cities), estado: pick(['SP','RJ','MG','PR','SC','RS','BA']), endereco: pick(['Rua das Flores','Av. Brasil','Rua XV de Novembro']), numero: String(randInt(10,9999)), bairro: pick(['Centro','Jardim Paulista','Vila Prado','Santa Felícia']), complemento: `Apto ${randInt(10,999)}`, escolaridade: pick(['Ensino Médio','Superior incompleto','Superior completo','Pós-graduação']), profissao: pick(['Estudante','Professora','Desenvolvedor','Enfermeira','Autônomo']), nome_parente: `${pick(first)} ${pick(last)}`, grau_parentesco: pick(['Mãe','Pai','Irmã','Irmão','Cônjuge']), telefone_parente: randomPhoneBR(), nome_responsavel: `${pick(first)} ${pick(last)} ${pick(last)}`, cpf_responsavel: fmtCPF(generateCPF()), telefone_responsavel: randomPhoneBR(), observacao_responsavel: 'Responsável ciente do contrato.', cobranca_no_responsavel: true, notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.', avatar_url: '' }
|
||||
if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
|
||||
if (Array.isArray(tags.value) && tags.value.length) { const sh = [...tags.value].sort(() => Math.random()-0.5); tagIdsSelecionadas.value = sh.slice(0, randInt(1, Math.min(3, tags.value.length))).map(t => t.id) }
|
||||
toast.add({ severity: 'info', summary: 'Preenchido', detail: 'Paciente preenchido com dados fictícios.', life: 2500 })
|
||||
}
|
||||
|
||||
const genderOptions = [
|
||||
{ label: 'Feminino', value: 'Feminino' },
|
||||
{ label: 'Masculino', value: 'Masculino' },
|
||||
{ label: 'Não-binário', value: 'Não-binário' },
|
||||
{ label: 'Prefere não informar', value: 'Prefere não informar' },
|
||||
{ label: 'Outro', value: 'Outro' }
|
||||
]
|
||||
const maritalStatusOptions = [
|
||||
{ label: 'Solteiro(a)', value: 'Solteiro(a)' },
|
||||
{ label: 'Casado(a)', value: 'Casado(a)' },
|
||||
{ label: 'União estável', value: 'União estável' },
|
||||
{ label: 'Divorciado(a)', value: 'Divorciado(a)' },
|
||||
{ label: 'Separado(a)', value: 'Separado(a)' },
|
||||
{ label: 'Viúvo(a)', value: 'Viúvo(a)' },
|
||||
{ label: 'Prefere não informar', value: 'Prefere não informar' }
|
||||
]
|
||||
|
||||
// ── Dialogs Grupo / Tag ───────────────────────────────────
|
||||
const createGroupDialog = ref(false); const createGroupSaving = ref(false); const createGroupError = ref(''); const newGroup = ref({ name: '', color: '#6366F1' })
|
||||
const createTagDialog = ref(false); const createTagSaving = ref(false); const createTagError = ref(''); const newTag = ref({ name: '', color: '#22C55E' })
|
||||
|
||||
function openGroupDlg () { createGroupError.value = ''; newGroup.value = { name: '', color: '#6366F1' }; createGroupDialog.value = true }
|
||||
function openTagDlg () { createTagError.value = ''; newTag.value = { name: '', color: '#22C55E' }; createTagDialog.value = true }
|
||||
|
||||
async function createGroupPersist () {
|
||||
if (createGroupSaving.value) return; createGroupError.value = ''
|
||||
const name = String(newGroup.value?.name || '').trim(); const color = String(newGroup.value?.color || '').trim() || '#6366F1'
|
||||
if (!name) { createGroupError.value = 'Informe um nome para o grupo.'; return }
|
||||
createGroupSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId(); const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { data, error } = await supabase.from('patient_groups').insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true }).select('id').single()
|
||||
if (error) throw error
|
||||
groups.value = await listGroups()
|
||||
if (data?.id) grupoIdSelecionado.value = data.id
|
||||
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 }); createGroupDialog.value = false
|
||||
} catch (e) {
|
||||
const msg = e?.message || ''
|
||||
createGroupError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe um grupo com esse nome.' : (msg || 'Falha ao criar grupo.')
|
||||
} finally { createGroupSaving.value = false }
|
||||
}
|
||||
|
||||
async function createTagPersist () {
|
||||
if (createTagSaving.value) return; createTagError.value = ''
|
||||
const name = String(newTag.value?.name || '').trim(); const color = String(newTag.value?.color || '').trim() || '#22C55E'
|
||||
if (!name) { createTagError.value = 'Informe um nome para a tag.'; return }
|
||||
createTagSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId(); const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { data, error } = await supabase.from('patient_tags').insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color }).select('id').single()
|
||||
if (error) throw error
|
||||
tags.value = await listTags()
|
||||
if (data?.id) { const set = new Set([...(tagIdsSelecionadas.value || []), data.id]); tagIdsSelecionadas.value = Array.from(set) }
|
||||
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 }); createTagDialog.value = false
|
||||
} catch (e) {
|
||||
const msg = e?.message || ''
|
||||
createTagError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao criar tag.')
|
||||
} finally { createTagSaving.value = false }
|
||||
}
|
||||
|
||||
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog v-if="!dialogMode" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky (oculto no modo dialog)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
v-if="!dialogMode"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-user-plus text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">
|
||||
{{ isEdit ? 'Editar paciente' : 'Cadastrar paciente' }}
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<template v-if="isEdit">Idade: <b class="text-[var(--text-color)]">{{ ageLabel }}</b></template>
|
||||
<template v-else">Preencha as informações do novo paciente</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Espaçador -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Ações (ocultas no modo dialog — o Dialog tem seu próprio footer) -->
|
||||
<div v-if="!dialogMode" class="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Preencher tudo"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full hidden xl:flex"
|
||||
@click="fillRandomPatient"
|
||||
/>
|
||||
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Voltar" @click="goBack" />
|
||||
<Button
|
||||
v-if="isEdit"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
title="Excluir paciente"
|
||||
:loading="deleting"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" @click="onSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
CORPO
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="px-3 md:px-4 pb-6">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] gap-2">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[260px_1fr] max-w-[1100px] mx-auto">
|
||||
|
||||
<!-- ── SIDEBAR ──────────────────────────────────── -->
|
||||
<aside
|
||||
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:self-start"
|
||||
:class="dialogMode ? 'xl:top-4' : 'xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)]'"
|
||||
>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-[var(--surface-border,#e2e8f0)] xl:flex-col xl:items-center xl:gap-2">
|
||||
<!-- Foto -->
|
||||
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] shrink-0">
|
||||
<img
|
||||
v-if="avatarPreviewUrl || form.avatar_url"
|
||||
:src="avatarPreviewUrl || form.avatar_url"
|
||||
alt="Avatar do paciente"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="grid w-full h-full place-items-center">
|
||||
<i class="pi pi-user text-2xl text-[var(--text-color-secondary)] opacity-40" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload -->
|
||||
<div class="flex-1 xl:w-full">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="block w-full text-[1rem] text-[var(--text-color-secondary)]
|
||||
file:mr-2 file:rounded-full file:border file:border-[var(--surface-border,#e2e8f0)]
|
||||
file:bg-[var(--surface-ground,#f8fafc)] file:px-3 file:py-1 file:text-[0.75rem]
|
||||
file:text-[var(--text-color)] file:cursor-pointer
|
||||
hover:file:bg-[var(--surface-hover,#f1f5f9)] hover:file:border-indigo-300"
|
||||
@change="onAvatarPicked"
|
||||
/>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||
Avatar opcional · máx 3 MB
|
||||
<span v-if="avatarUploading" class="ml-1 text-indigo-500">(enviando…)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav — desktop (≥ xl) -->
|
||||
<div v-if="!isCompact" class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[1rem] border transition-colors duration-100"
|
||||
:class="activeValue === item.value
|
||||
? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold'
|
||||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="openPanel(Number(item.value))"
|
||||
>
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── MAIN ──────────────────────────────────────── -->
|
||||
<main class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
|
||||
<!-- Nav compacto (<xl) -->
|
||||
<div v-if="isCompact" class="sticky top-[calc(var(--layout-sticky-top,56px)+3.5rem)] z-30 border-b border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3.5 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="w-full !rounded-full"
|
||||
icon="pi pi-chevron-down"
|
||||
iconPos="right"
|
||||
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
|
||||
@click="toggleNav($event)"
|
||||
/>
|
||||
<Popover ref="navPopover" :pt="{ root: { class: 'z-[9999999]' } }">
|
||||
<div class="flex min-w-[240px] flex-col gap-1 p-1">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[1rem] border border-transparent cursor-pointer"
|
||||
:class="activeValue === item.value ? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold' : 'text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="selectNav(item)"
|
||||
>
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<Accordion :multiple="false" v-model:value="activeValue">
|
||||
|
||||
<!-- ─── 0: Informações pessoais ──────────── -->
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 0)">1. Informações pessoais</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-user" /><InputText id="f_nome" v-model="form.nome_completo" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-phone" /><InputMask id="f_telefone" v-model="form.telefone" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_telefone">Telefone / celular *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-envelope" /><InputText id="f_email" v-model="form.email_principal" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_email">E-mail principal *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-envelope" /><InputText id="f_email_alt" v-model="form.email_alternativo" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_email_alt">E-mail alternativo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-phone" /><InputMask id="f_tel_alt" v-model="form.telefone_alternativo" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_tel_alt">Telefone alternativo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-calendar" /><InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_nasc">Data de nascimento</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-user" /><Select id="f_genero" v-model="form.genero" :options="genderOptions" optionLabel="label" optionValue="value" class="w-full pl-[25px]" variant="filled" /></IconField>
|
||||
<label for="f_genero">Gênero</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-heart" /><Select id="f_estado_civil" v-model="form.estado_civil" :options="maritalStatusOptions" optionLabel="label" optionValue="value" class="w-full pl-[25px]" variant="filled" /></IconField>
|
||||
<label for="f_estado_civil">Estado civil</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-id-card" /><InputMask id="f_cpf" v-model="form.cpf" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_cpf">CPF</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-id-card" /><InputText id="f_rg" v-model="form.rg" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_rg">RG</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-map" /><InputText id="f_nat" v-model="form.naturalidade" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_nat">Naturalidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<Textarea id="f_obs" v-model="form.observacoes" rows="3" class="w-full" variant="filled" />
|
||||
<label for="f_obs">Observações</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<!-- Grupo -->
|
||||
<div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-folder-open" /><Select id="f_group" v-model="grupoIdSelecionado" :options="groups" optionLabel="nome" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled" /></IconField>
|
||||
<label for="f_group">Grupo</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">Usado para puxar um modelo de anamnese.</div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openGroupDlg" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-tag" /><MultiSelect id="f_tags" v-model="tagIdsSelecionadas" :options="tags" optionLabel="name" optionValue="id" class="w-full pl-[25px]" display="chip" filter variant="filled" /></IconField>
|
||||
<label for="f_tags">Tags</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openTagDlg" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-megaphone" /><InputText id="f_lead" v-model="form.onde_nos_conheceu" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_lead">Como chegou até mim?</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-share-alt" /><InputText id="f_ref" v-model="form.encaminhado_por" class="w-full" variant="filled" /></IconField>
|
||||
<label for="f_ref">Encaminhado por</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- ─── 1: Endereço ──────────────────────── -->
|
||||
<AccordionPanel value="1">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 1)">2. Endereço</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-map-marker" /><InputText id="f_cep" v-model="form.cep" class="w-full" @blur="onCepBlur" variant="filled" /></IconField><label for="f_cep">CEP</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-globe" /><InputText id="f_country" v-model="form.pais" class="w-full" variant="filled" /></IconField><label for="f_country">País</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-building" /><InputText id="f_city" v-model="form.cidade" class="w-full" variant="filled" /></IconField><label for="f_city">Cidade</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-compass" /><InputText id="f_state" v-model="form.estado" class="w-full" variant="filled" /></IconField><label for="f_state">Estado</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-map" /><InputText id="f_address" v-model="form.endereco" class="w-full" variant="filled" /></IconField><label for="f_address">Endereço</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-hashtag" /><InputText id="f_number" v-model="form.numero" class="w-full" variant="filled" /></IconField><label for="f_number">Número</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-map-marker" /><InputText id="f_neighborhood" v-model="form.bairro" class="w-full" variant="filled" /></IconField><label for="f_neighborhood">Bairro</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-align-left" /><InputText id="f_complement" v-model="form.complemento" class="w-full" variant="filled" /></IconField><label for="f_complement">Complemento</label></FloatLabel></div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- ─── 2: Dados adicionais ──────────────── -->
|
||||
<AccordionPanel value="2">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 2)">3. Dados adicionais</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-book" /><InputText id="f_escolaridade" v-model="form.escolaridade" class="w-full" variant="filled" /></IconField><label for="f_escolaridade">Escolaridade</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-briefcase" /><InputText id="f_profissao" v-model="form.profissao" class="w-full" variant="filled" /></IconField><label for="f_profissao">Profissão</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-user" /><InputText id="f_parente_nome" v-model="form.nome_parente" class="w-full" variant="filled" /></IconField><label for="f_parente_nome">Nome de um parente</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-users" /><InputText id="f_parentesco" v-model="form.grau_parentesco" class="w-full" variant="filled" /></IconField><label for="f_parentesco">Grau de parentesco</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-phone" /><InputMask id="f_parente_tel" v-model="form.telefone_parente" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField><label for="f_parente_tel">Telefone do parente</label></FloatLabel></div>
|
||||
<div class="xl:col-span-2">
|
||||
<Button icon="pi pi-plus" label="Adicionar mais parentes (em breve)" severity="secondary" outlined disabled />
|
||||
<div class="mt-1.5 text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">Se você quiser, isso vira uma lista (1:N) depois.</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- ─── 3: Responsável ───────────────────── -->
|
||||
<AccordionPanel value="3">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 3)">4. Responsável</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 pt-1">
|
||||
<div class="xl:col-span-2"><FloatLabel variant="on"><IconField><InputIcon class="pi pi-user" /><InputText id="f_resp_nome" v-model="form.nome_responsavel" class="w-full" variant="filled" /></IconField><label for="f_resp_nome">Nome do responsável</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-id-card" /><InputMask id="f_resp_cpf" v-model="form.cpf_responsavel" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled" /></IconField><label for="f_resp_cpf">CPF do responsável</label></FloatLabel></div>
|
||||
<div><FloatLabel variant="on"><IconField><InputIcon class="pi pi-phone" /><InputMask id="f_resp_tel" v-model="form.telefone_responsavel" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" /></IconField><label for="f_resp_tel">Telefone do responsável</label></FloatLabel></div>
|
||||
<div class="xl:col-span-2"><FloatLabel variant="on"><Textarea id="f_resp_obs" v-model="form.observacao_responsavel" rows="3" class="w-full" variant="filled" /><label for="f_resp_obs">Observações sobre o responsável</label></FloatLabel></div>
|
||||
<div class="xl:col-span-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox inputId="f_bill" v-model="form.cobranca_no_responsavel" :binary="true" />
|
||||
<label for="f_bill" class="text-[1rem] text-[var(--text-color)] cursor-pointer">Cobrança no responsável</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- ─── 4: Anotações internas ────────────── -->
|
||||
<AccordionPanel value="4">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)">5. Anotações internas</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="mb-2.5 text-[0.75rem] text-[var(--text-color-secondary)] opacity-70 flex items-center gap-1.5">
|
||||
<i class="pi pi-lock text-[1rem]" />
|
||||
Campo interno: não aparece no cadastro externo.
|
||||
</div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea id="f_notas" v-model="form.notas_internas" rows="7" class="w-full" variant="filled" />
|
||||
<label for="f_notas">Notas internas</label>
|
||||
</FloatLabel>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
</Accordion>
|
||||
|
||||
<!-- Botão salvar bottom (oculto no modo dialog — o footer cuida disso) -->
|
||||
<div v-if="!dialogMode" class="mt-4 flex justify-center">
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="saving" class="min-w-[200px] rounded-full" @click="onSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Criar grupo
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="createGroupDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar grupo"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createGroupSaving"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie um grupo para organizar seus pacientes.</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="group-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
|
||||
<InputText id="group-name" v-model="newGroup.name" class="flex-1" autocomplete="off" placeholder="Ex: Crianças" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
|
||||
<div class="flex flex-1 items-center gap-2.5">
|
||||
<input v-model="newGroup.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
|
||||
<Chip :label="newGroup.color || '#—'" class="font-semibold" :style="{ backgroundColor: newGroup.color, color: '#fff' }" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="createGroupError" class="text-[1rem] text-red-500">{{ createGroupError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="createGroupSaving" @click="createGroupDialog = false" />
|
||||
<Button label="Criar" icon="pi pi-check" class="rounded-full" :loading="createGroupSaving" @click="createGroupPersist" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Criar tag
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="createTagDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar tag"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createTagSaving"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie uma tag para facilitar filtros e organização.</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="tag-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
|
||||
<InputText id="tag-name" v-model="newTag.name" class="flex-1" autocomplete="off" placeholder="Ex: Ansiedade" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
|
||||
<div class="flex flex-1 items-center gap-2.5">
|
||||
<input v-model="newTag.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
|
||||
<Chip :label="newTag.color || '#—'" class="font-semibold" :style="{ backgroundColor: newTag.color, color: '#fff' }" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="createTagError" class="text-[1rem] text-red-500">{{ createTagError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="createTagSaving" @click="createTagDialog = false" />
|
||||
<Button label="Criar" icon="pi pi-check" class="rounded-full" :loading="createTagSaving" @click="createTagPersist" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,679 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/detail/PatientsDetailPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ── Mock data ─────────────────────────────────────────────
|
||||
const patient = ref({
|
||||
nome_completo: 'Mariana Lima',
|
||||
nome_social: null,
|
||||
pronomes: 'ela/dela',
|
||||
data_nascimento: '1992-06-14',
|
||||
cpf: '12345678900',
|
||||
genero: 'Feminino',
|
||||
estado_civil: 'Solteira',
|
||||
escolaridade: 'Superior completo',
|
||||
profissao: 'Desenvolvedora',
|
||||
etnia: null,
|
||||
telefone: '(16) 99123-4567',
|
||||
email: 'mariana@email.com',
|
||||
canal_preferido: 'WhatsApp',
|
||||
horario_contato: '08h–20h',
|
||||
cep: '13560-000',
|
||||
cidade: 'São Carlos',
|
||||
estado: 'SP',
|
||||
status: 'Ativo',
|
||||
risco_elevado: true,
|
||||
risco_nota: 'Ideação passiva relatada em 12/03',
|
||||
risco_sinalizado_por: 'Dra. Ana Lima',
|
||||
risco_sinalizado_em: '2025-03-12',
|
||||
tags: [{ nome: 'Ansiedade', cor: '#7F77DD' }, { nome: 'TCC', cor: '#1D9E75' }],
|
||||
convenio: 'Unimed',
|
||||
patient_scope: 'Clínica',
|
||||
origem: 'Indicação',
|
||||
encaminhado_por: 'Dr. Roberto (psiq.)',
|
||||
metodo_pagamento_preferido: 'PIX',
|
||||
motivo_saida: null,
|
||||
metricas: {
|
||||
total_sessoes: 47,
|
||||
taxa_comparecimento: 92,
|
||||
ltv_total: 8460,
|
||||
dias_sem_sessao: 18,
|
||||
taxa_pagamentos: 100,
|
||||
taxa_tarefas: 60,
|
||||
engajamento_score: 84,
|
||||
duracao_meses: 14,
|
||||
proxima_sessao: '27/03 às 14h'
|
||||
}
|
||||
})
|
||||
|
||||
const contatos = ref([
|
||||
{ nome: 'Maria Lima', tipo: 'emergencia', relacao: 'mãe', telefone: '(16) 98888-0001', email: 'maria@email.com', is_primario: true },
|
||||
{ nome: 'Dr. Roberto Oliveira', tipo: 'profissional_saude', relacao: 'psiquiatra', telefone: '(16) 3322-1100', email: null, is_primario: false }
|
||||
])
|
||||
|
||||
const timeline = ref([
|
||||
{ tipo: 'risco_sinalizado', titulo: 'Risco elevado sinalizado', descricao: 'Ideação passiva relatada', cor: 'red', data: '12/03/2025', autor: 'Dra. Ana Lima' },
|
||||
{ tipo: 'escala_respondida', titulo: 'GAD-7 respondido', descricao: 'Score 12 — ansiedade moderada', cor: 'green', data: '10/03/2025', autor: 'via portal' },
|
||||
{ tipo: 'documento_assinado', titulo: 'TCLE assinado digitalmente', descricao: null, cor: 'blue', data: '02/01/2024', autor: 'via portal' },
|
||||
{ tipo: 'primeira_sessao', titulo: 'Primeira sessão realizada', descricao: 'Presencial · 50min', cor: 'green', data: '15/01/2024', autor: null }
|
||||
])
|
||||
|
||||
// ── Computed helpers ──────────────────────────────────────
|
||||
const idade = computed(() => {
|
||||
if (!patient.value.data_nascimento) return null
|
||||
const birth = new Date(patient.value.data_nascimento)
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - birth.getFullYear()
|
||||
const m = now.getMonth() - birth.getMonth()
|
||||
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--
|
||||
return age
|
||||
})
|
||||
|
||||
const cpfMascarado = computed(() => {
|
||||
const cpf = patient.value.cpf || ''
|
||||
if (cpf.length < 2) return cpf
|
||||
const visible = cpf.slice(-2)
|
||||
const hidden = '•'.repeat(cpf.length - 2)
|
||||
return hidden + visible
|
||||
})
|
||||
|
||||
const iniciais = computed(() => {
|
||||
return (patient.value.nome_completo || '')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(w => w[0].toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
})
|
||||
|
||||
function initiaisFor(nome) {
|
||||
return (nome || '')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(w => w[0].toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function dataNascFormatada(iso) {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function progressSeverity(val) {
|
||||
if (val >= 80) return 'success'
|
||||
if (val >= 60) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function progressColor(val) {
|
||||
if (val >= 80) return 'var(--p-green-500)'
|
||||
if (val >= 60) return 'var(--p-yellow-500)'
|
||||
return 'var(--p-red-500)'
|
||||
}
|
||||
|
||||
function scoreClass(val) {
|
||||
if (val >= 80) return 'text-green-500'
|
||||
if (val >= 60) return 'text-yellow-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
function timelineMarkerStyle(cor) {
|
||||
const map = {
|
||||
red: 'var(--p-red-500)',
|
||||
green: 'var(--p-green-500)',
|
||||
blue: 'var(--p-blue-500)',
|
||||
gray: 'var(--p-surface-400)'
|
||||
}
|
||||
return { background: map[cor] || map.gray }
|
||||
}
|
||||
|
||||
function timelineIcon(tipo) {
|
||||
const map = {
|
||||
risco_sinalizado: 'pi pi-exclamation-triangle',
|
||||
escala_respondida: 'pi pi-chart-bar',
|
||||
documento_assinado: 'pi pi-file-check',
|
||||
primeira_sessao: 'pi pi-star'
|
||||
}
|
||||
return map[tipo] || 'pi pi-circle'
|
||||
}
|
||||
|
||||
function val(v) {
|
||||
return v ?? '—'
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push('/admin/pacientes')
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────
|
||||
const activeTab = ref(0)
|
||||
const tabs = [
|
||||
{ label: 'Perfil', icon: 'pi pi-user' },
|
||||
{ label: 'Prontuário', icon: 'pi pi-clipboard' },
|
||||
{ label: 'Agenda', icon: 'pi pi-calendar' },
|
||||
{ label: 'Financeiro', icon: 'pi pi-wallet' },
|
||||
{ label: 'Documentos', icon: 'pi pi-folder' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-screen bg-[var(--surface-ground)]">
|
||||
|
||||
<!-- ── Alerta de risco elevado ─────────────────────── -->
|
||||
<Message
|
||||
v-if="patient.risco_elevado"
|
||||
severity="error"
|
||||
:closable="false"
|
||||
class="rounded-none border-0 border-b border-red-400 m-0"
|
||||
pt:root:class="rounded-none"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="pi pi-exclamation-circle text-xl mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div class="font-bold text-[1rem]">Atenção — paciente com risco elevado sinalizado</div>
|
||||
<div class="text-[0.85rem] opacity-90 mt-0.5">
|
||||
Sinalizado em {{ patient.risco_sinalizado_em?.split('-').reverse().join('/') }}
|
||||
por {{ patient.risco_sinalizado_por }}
|
||||
<span v-if="patient.risco_nota"> · {{ patient.risco_nota }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
|
||||
<!-- ── Barra superior ─────────────────────────────── -->
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-card)] border-b border-[var(--surface-border)]">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
label="Pacientes"
|
||||
severity="secondary"
|
||||
text
|
||||
class="font-semibold"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Editar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
label="Sessão"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Card cabeçalho ─────────────────────────────── -->
|
||||
<div class="px-4 pt-4 pb-0">
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
|
||||
|
||||
<!-- Avatar -->
|
||||
<Avatar
|
||||
:label="iniciais"
|
||||
size="xlarge"
|
||||
shape="circle"
|
||||
class="shrink-0 text-white font-bold text-xl"
|
||||
style="background: var(--p-primary-500); width: 4.5rem; height: 4.5rem; font-size: 1.4rem;"
|
||||
/>
|
||||
|
||||
<!-- Nome + badges + métricas -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Nome + info rápida -->
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)] leading-tight">
|
||||
{{ patient.nome_completo }}
|
||||
</span>
|
||||
<span class="text-[var(--text-color-secondary)] text-[0.95rem]">
|
||||
{{ idade }} anos · {{ patient.pronomes }} · {{ patient.cidade }}/{{ patient.estado }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Tag :value="patient.status" severity="success" />
|
||||
<Tag :value="patient.convenio" severity="info" />
|
||||
<Tag :value="patient.patient_scope" severity="secondary" />
|
||||
<Tag
|
||||
v-for="tag in patient.tags"
|
||||
:key="tag.nome"
|
||||
:value="tag.nome"
|
||||
:style="{ background: tag.cor, color: '#fff', border: 'none' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Métricas em linha -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)]">{{ patient.metricas.total_sessoes }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Total sessões</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold" :class="scoreClass(patient.metricas.taxa_comparecimento)">{{ patient.metricas.taxa_comparecimento }}%</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Comparecimento</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)]">R$ {{ patient.metricas.ltv_total.toLocaleString('pt-BR') }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">LTV total</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)]">{{ patient.metricas.dias_sem_sessao }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Dias s/ sessão</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- ── Tabs ───────────────────────────────────────── -->
|
||||
<div class="px-4 pt-3 pb-6 flex-1">
|
||||
<TabView v-model:activeIndex="activeTab" class="shadow-none">
|
||||
|
||||
<!-- ══ Aba: Perfil ════════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-user" />
|
||||
Perfil
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 mt-2">
|
||||
|
||||
<!-- ─── Coluna esquerda ─────────────────── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Dados pessoais -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-id-card text-[var(--p-primary-500)]" />
|
||||
Dados pessoais
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<table class="w-full text-[0.9rem]">
|
||||
<tbody>
|
||||
<tr v-for="row in [
|
||||
{ label: 'Nome completo', value: patient.nome_completo },
|
||||
{ label: 'Nome social', value: patient.nome_social },
|
||||
{ label: 'Pronomes', value: patient.pronomes },
|
||||
{ label: 'Nascimento', value: `${dataNascFormatada(patient.data_nascimento)} (${idade} anos)` },
|
||||
{ label: 'CPF', value: cpfMascarado },
|
||||
{ label: 'Gênero', value: patient.genero },
|
||||
{ label: 'Estado civil', value: patient.estado_civil },
|
||||
{ label: 'Escolaridade', value: patient.escolaridade },
|
||||
{ label: 'Profissão', value: patient.profissao },
|
||||
{ label: 'Etnia', value: patient.etnia },
|
||||
]" :key="row.label" class="border-b border-[var(--surface-border)] last:border-0">
|
||||
<td class="py-2 pr-4 w-[38%] text-[var(--text-color-secondary)] font-medium align-top">{{ row.label }}</td>
|
||||
<td class="py-2 align-top" :class="{ 'text-[var(--text-color-secondary)] italic': !row.value }">
|
||||
{{ row.value || '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Contato -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-phone text-[var(--p-primary-500)]" />
|
||||
Contato
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<table class="w-full text-[0.9rem]">
|
||||
<tbody>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 w-[38%] text-[var(--text-color-secondary)] font-medium">Telefone</td>
|
||||
<td class="py-2">
|
||||
<a :href="`tel:${patient.telefone}`" class="text-[var(--p-primary-500)] hover:underline">{{ patient.telefone }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">E-mail</td>
|
||||
<td class="py-2">
|
||||
<a :href="`mailto:${patient.email}`" class="text-[var(--p-primary-500)] hover:underline">{{ patient.email }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Canal preferido</td>
|
||||
<td class="py-2">{{ val(patient.canal_preferido) }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Horário</td>
|
||||
<td class="py-2">{{ val(patient.horario_contato) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Cidade</td>
|
||||
<td class="py-2">{{ patient.cep ? patient.cep + ' · ' : '' }}{{ patient.cidade }}/{{ patient.estado }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ─── Coluna direita ──────────────────── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Origem -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-send text-[var(--p-primary-500)]" />
|
||||
Origem
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<table class="w-full text-[0.9rem]">
|
||||
<tbody>
|
||||
<tr v-for="row in [
|
||||
{ label: 'Como chegou', value: patient.origem },
|
||||
{ label: 'Encaminhado por', value: patient.encaminhado_por },
|
||||
{ label: 'Pagamento', value: patient.metodo_pagamento_preferido },
|
||||
{ label: 'Motivo de saída', value: patient.motivo_saida },
|
||||
]" :key="row.label" class="border-b border-[var(--surface-border)] last:border-0">
|
||||
<td class="py-2 pr-4 w-[40%] text-[var(--text-color-secondary)] font-medium align-top">{{ row.label }}</td>
|
||||
<td class="py-2 align-top" :class="{ 'text-[var(--text-color-secondary)] italic': !row.value }">
|
||||
{{ row.value || '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Contatos & rede de suporte -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-users text-[var(--p-primary-500)]" />
|
||||
Contatos & rede de suporte
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="contato in contatos"
|
||||
:key="contato.nome"
|
||||
class="flex items-start gap-3 p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-section)]"
|
||||
>
|
||||
<Avatar
|
||||
:label="initiaisFor(contato.nome)"
|
||||
shape="circle"
|
||||
class="shrink-0 text-white font-bold"
|
||||
style="background: var(--p-primary-300); width: 2.5rem; height: 2.5rem;"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-[0.92rem]">{{ contato.nome }}</span>
|
||||
<Tag
|
||||
:value="contato.relacao"
|
||||
severity="secondary"
|
||||
class="text-[0.72rem]"
|
||||
/>
|
||||
<Tag
|
||||
v-if="contato.is_primario"
|
||||
value="emergência"
|
||||
severity="danger"
|
||||
class="text-[0.72rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-[0.82rem] text-[var(--text-color-secondary)] flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
<span v-if="contato.telefone">
|
||||
<i class="pi pi-phone mr-1" />
|
||||
<a :href="`tel:${contato.telefone}`" class="hover:underline">{{ contato.telefone }}</a>
|
||||
</span>
|
||||
<span v-if="contato.email">
|
||||
<i class="pi pi-envelope mr-1" />
|
||||
<a :href="`mailto:${contato.email}`" class="hover:underline">{{ contato.email }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
label="Adicionar contato"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Engajamento -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-chart-line text-[var(--p-primary-500)]" />
|
||||
Engajamento
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- Barras de progresso -->
|
||||
<div class="flex flex-col gap-4 mb-5">
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.82rem] mb-1">
|
||||
<span class="text-[var(--text-color-secondary)]">Comparecimento</span>
|
||||
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_comparecimento) }">
|
||||
{{ patient.metricas.taxa_comparecimento }}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="patient.metricas.taxa_comparecimento"
|
||||
:showValue="false"
|
||||
:class="`progress-${progressSeverity(patient.metricas.taxa_comparecimento)}`"
|
||||
style="height: 8px; border-radius: 99px;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.82rem] mb-1">
|
||||
<span class="text-[var(--text-color-secondary)]">Pagamentos em dia</span>
|
||||
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_pagamentos) }">
|
||||
{{ patient.metricas.taxa_pagamentos }}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="patient.metricas.taxa_pagamentos"
|
||||
:showValue="false"
|
||||
:class="`progress-${progressSeverity(patient.metricas.taxa_pagamentos)}`"
|
||||
style="height: 8px; border-radius: 99px;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.82rem] mb-1">
|
||||
<span class="text-[var(--text-color-secondary)]">Tarefas concluídas</span>
|
||||
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_tarefas) }">
|
||||
{{ patient.metricas.taxa_tarefas }}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="patient.metricas.taxa_tarefas"
|
||||
:showValue="false"
|
||||
:class="`progress-${progressSeverity(patient.metricas.taxa_tarefas)}`"
|
||||
style="height: 8px; border-radius: 99px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score + info -->
|
||||
<div class="flex items-center gap-4 p-4 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<div class="flex flex-col items-center shrink-0">
|
||||
<span
|
||||
class="text-4xl font-black leading-none"
|
||||
:class="scoreClass(patient.metricas.engajamento_score)"
|
||||
>{{ patient.metricas.engajamento_score }}</span>
|
||||
<span class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1 uppercase tracking-wide">Score</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1 text-[0.85rem]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-clock text-[var(--text-color-secondary)]" />
|
||||
<span>{{ patient.metricas.duracao_meses }} meses em tratamento</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-calendar text-[var(--p-primary-500)]" />
|
||||
<span>Próxima sessão: <strong>{{ patient.metricas.proxima_sessao }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Linha do tempo (full width) ─────────── -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)] mt-4">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-history text-[var(--p-primary-500)]" />
|
||||
Linha do tempo
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<Timeline :value="timeline" class="customized-timeline">
|
||||
<template #marker="{ item }">
|
||||
<span
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full text-white text-[0.8rem] shadow"
|
||||
:style="timelineMarkerStyle(item.cor)"
|
||||
>
|
||||
<i :class="timelineIcon(item.tipo)" />
|
||||
</span>
|
||||
</template>
|
||||
<template #content="{ item }">
|
||||
<div class="pb-5">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 mb-0.5">
|
||||
<span class="font-semibold text-[0.92rem]">{{ item.titulo }}</span>
|
||||
<span class="text-[0.78rem] text-[var(--text-color-secondary)]">{{ item.data }}</span>
|
||||
<span v-if="item.autor" class="text-[0.78rem] text-[var(--text-color-secondary)]">· {{ item.autor }}</span>
|
||||
</div>
|
||||
<p v-if="item.descricao" class="text-[0.85rem] text-[var(--text-color-secondary)] mt-0.5 m-0">
|
||||
{{ item.descricao }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Timeline>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Prontuário ════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-clipboard" />
|
||||
Prontuário
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-clipboard text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Prontuário — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Agenda ════════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-calendar" />
|
||||
Agenda
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-calendar text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Agenda — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Financeiro ════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-wallet" />
|
||||
Financeiro
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-wallet text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Financeiro — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Documentos ════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-folder" />
|
||||
Documentos
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-folder text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Documentos — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
</TabView>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ProgressBar color overrides via severity class */
|
||||
:deep(.progress-success .p-progressbar-value) {
|
||||
background: var(--p-green-500) !important;
|
||||
}
|
||||
:deep(.progress-warning .p-progressbar-value) {
|
||||
background: var(--p-yellow-500) !important;
|
||||
}
|
||||
:deep(.progress-danger .p-progressbar-value) {
|
||||
background: var(--p-red-500) !important;
|
||||
}
|
||||
|
||||
/* Timeline connector line */
|
||||
:deep(.p-timeline-event-connector) {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
/* Remove TabView shadow */
|
||||
:deep(.p-tabview .p-tabview-panels) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/medicos/MedicosPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Menu from 'primevue/menu';
|
||||
|
||||
import {
|
||||
listMedicosWithPatientCounts,
|
||||
createMedico,
|
||||
updateMedico,
|
||||
deleteMedico,
|
||||
fetchPatientsByMedicoNome
|
||||
} from '@/services/Medicos.service.js';
|
||||
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null);
|
||||
const headerSentinelRef = ref(null);
|
||||
const headerStuck = ref(false);
|
||||
let _observer = null;
|
||||
|
||||
// ── Mobile ────────────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null);
|
||||
const searchDlgOpen = ref(false);
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Adicionar médico', icon: 'pi pi-plus', command: () => openCreate() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchDlgOpen.value = true; } },
|
||||
{ separator: true },
|
||||
...(selectedMedicos.value?.length
|
||||
? [{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmDeleteSelected() }, { separator: true }]
|
||||
: []),
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||
]);
|
||||
|
||||
const dt = ref(null);
|
||||
const loading = ref(false);
|
||||
const hasLoaded = ref(false);
|
||||
const medicos = ref([]);
|
||||
const selectedMedicos = ref([]);
|
||||
|
||||
const filters = ref({ global: { value: null, matchMode: 'contains' } });
|
||||
|
||||
// ── Especialidades ────────────────────────────────────────
|
||||
const especialidadesOpts = [
|
||||
{ label: 'Psiquiatria', value: 'Psiquiatria' },
|
||||
{ label: 'Neurologia', value: 'Neurologia' },
|
||||
{ label: 'Neuropsiquiatria infantil', value: 'Neuropsiquiatria infantil' },
|
||||
{ label: 'Clínica geral', value: 'Clínica geral' },
|
||||
{ label: 'Pediatria', value: 'Pediatria' },
|
||||
{ label: 'Geriatria', value: 'Geriatria' },
|
||||
{ label: 'Endocrinologia', value: 'Endocrinologia' },
|
||||
{ label: 'Psicologia (encaminhador)', value: 'Psicologia (encaminhador)' },
|
||||
{ label: 'Assistência social', value: 'Assistência social' },
|
||||
{ label: 'Fonoaudiologia', value: 'Fonoaudiologia' },
|
||||
{ label: 'Terapia ocupacional', value: 'Terapia ocupacional' },
|
||||
{ label: 'Fisioterapia', value: 'Fisioterapia' },
|
||||
{ label: 'Outra', value: '__outra__' },
|
||||
];
|
||||
|
||||
// ── Quick-stats ───────────────────────────────────────────
|
||||
const quickStats = computed(() => {
|
||||
const all = medicos.value || [];
|
||||
const comPacs = cards.value.length;
|
||||
const totalPacs = all.reduce((s, m) => s + Number(m.patients_count ?? 0), 0);
|
||||
const especialidades = new Set(all.map((m) => m.especialidade).filter(Boolean)).size;
|
||||
return [
|
||||
{ label: 'Total de médicos', value: all.length, cls: '' },
|
||||
{ label: 'Especialidades', value: especialidades, cls: '' },
|
||||
{ label: 'Com pacientes', value: comPacs, cls: comPacs > 0 ? 'qs-ok' : '' },
|
||||
{ label: 'Total encaminhados', value: totalPacs, cls: totalPacs > 0 ? 'qs-ok' : '' }
|
||||
];
|
||||
});
|
||||
|
||||
// ── Dialog Criar/Editar ──────────────────────────────────
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
crm: '',
|
||||
especialidade: '',
|
||||
especialidade_outra: '',
|
||||
telefone_profissional: '',
|
||||
telefone_pessoal: '',
|
||||
email: '',
|
||||
clinica: '',
|
||||
cidade: '',
|
||||
estado: 'SP',
|
||||
observacoes: '',
|
||||
saving: false,
|
||||
error: ''
|
||||
});
|
||||
|
||||
const especialidadeFinal = computed(() =>
|
||||
dlg.especialidade === '__outra__'
|
||||
? (dlg.especialidade_outra.trim() || null)
|
||||
: (dlg.especialidade || null)
|
||||
);
|
||||
|
||||
// ── Dialog pacientes ──────────────────────────────────────
|
||||
const patientsDialog = reactive({ open: false, loading: false, error: '', medico: null, items: [], search: '' });
|
||||
|
||||
// ── Cards painel lateral ──────────────────────────────────
|
||||
const cards = computed(() =>
|
||||
(medicos.value || [])
|
||||
.filter((m) => Number(m.patients_count ?? 0) > 0)
|
||||
.sort((a, b) => Number(b.patients_count ?? 0) - Number(a.patients_count ?? 0))
|
||||
);
|
||||
|
||||
const patientsDialogFiltered = computed(() => {
|
||||
const s = String(patientsDialog.search || '').trim().toLowerCase();
|
||||
if (!s) return patientsDialog.items || [];
|
||||
return (patientsDialog.items || []).filter(
|
||||
(p) =>
|
||||
String(p.full_name || '').toLowerCase().includes(s) ||
|
||||
String(p.email || '').toLowerCase().includes(s) ||
|
||||
String(p.phone || '').toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
function patientsLabel(n) {
|
||||
return n === 1 ? '1 paciente' : `${n} pacientes`;
|
||||
}
|
||||
|
||||
function humanizeError(err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.';
|
||||
const code = err?.code;
|
||||
if (code === '23505' || /duplicate key value/i.test(msg)) return 'Já existe um médico com este CRM.';
|
||||
return msg;
|
||||
}
|
||||
|
||||
// ── Fetch ─────────────────────────────────────────────────
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
medicos.value = await listMedicosWithPatientCounts();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
hasLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seleção ───────────────────────────────────────────────
|
||||
function isSelected(row) {
|
||||
return (selectedMedicos.value || []).some((s) => s.id === row.id);
|
||||
}
|
||||
function toggleRowSelection(row, checked) {
|
||||
const sel = selectedMedicos.value || [];
|
||||
selectedMedicos.value = checked
|
||||
? (sel.some((s) => s.id === row.id) ? sel : [...sel, row])
|
||||
: sel.filter((s) => s.id !== row.id);
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────
|
||||
function openCreate() {
|
||||
dlg.open = true;
|
||||
dlg.mode = 'create';
|
||||
dlg.id = '';
|
||||
dlg.nome = '';
|
||||
dlg.crm = '';
|
||||
dlg.especialidade = '';
|
||||
dlg.especialidade_outra = '';
|
||||
dlg.telefone_profissional = '';
|
||||
dlg.telefone_pessoal = '';
|
||||
dlg.email = '';
|
||||
dlg.clinica = '';
|
||||
dlg.cidade = '';
|
||||
dlg.estado = 'SP';
|
||||
dlg.observacoes = '';
|
||||
dlg.error = '';
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
dlg.open = true;
|
||||
dlg.mode = 'edit';
|
||||
dlg.id = row.id;
|
||||
dlg.nome = row.nome || '';
|
||||
dlg.crm = row.crm || '';
|
||||
dlg.especialidade = row.especialidade || '';
|
||||
dlg.especialidade_outra = '';
|
||||
dlg.telefone_profissional = fmtPhone(row.telefone_profissional);
|
||||
dlg.telefone_pessoal = fmtPhone(row.telefone_pessoal);
|
||||
dlg.email = row.email || '';
|
||||
dlg.clinica = row.clinica || '';
|
||||
dlg.cidade = row.cidade || '';
|
||||
dlg.estado = row.estado || 'SP';
|
||||
dlg.observacoes = row.observacoes || '';
|
||||
dlg.error = '';
|
||||
}
|
||||
|
||||
async function saveDialog() {
|
||||
const nome = String(dlg.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlg.error = 'Informe o nome do médico.';
|
||||
return;
|
||||
}
|
||||
if (dlg.especialidade === '__outra__' && !dlg.especialidade_outra.trim()) {
|
||||
dlg.error = 'Informe a especialidade.';
|
||||
return;
|
||||
}
|
||||
|
||||
dlg.saving = true;
|
||||
dlg.error = '';
|
||||
|
||||
const payload = {
|
||||
nome,
|
||||
crm: dlg.crm.trim() || null,
|
||||
especialidade: especialidadeFinal.value,
|
||||
telefone_profissional: dlg.telefone_profissional ? digitsOnly(dlg.telefone_profissional) : null,
|
||||
telefone_pessoal: dlg.telefone_pessoal ? digitsOnly(dlg.telefone_pessoal) : null,
|
||||
email: dlg.email.trim() || null,
|
||||
clinica: dlg.clinica.trim() || null,
|
||||
cidade: dlg.cidade.trim() || null,
|
||||
estado: dlg.estado.trim() || null,
|
||||
observacoes: dlg.observacoes.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await createMedico(payload);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico cadastrado.', life: 2500 });
|
||||
} else {
|
||||
await updateMedico(dlg.id, payload);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico atualizado.', life: 2500 });
|
||||
}
|
||||
dlg.open = false;
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
dlg.error = humanizeError(err);
|
||||
} finally {
|
||||
dlg.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteOne(row) {
|
||||
confirm.require({
|
||||
message: `Desativar "Dr(a). ${row.nome}"? O registro será ocultado da listagem.`,
|
||||
header: 'Desativar médico',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteMedico(row.id);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico desativado.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteSelected() {
|
||||
const sel = selectedMedicos.value || [];
|
||||
if (!sel.length) return;
|
||||
confirm.require({
|
||||
message: `Desativar ${sel.length} médico(s)?`,
|
||||
header: 'Desativar selecionados',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
for (const m of sel) await deleteMedico(m.id);
|
||||
selectedMedicos.value = [];
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médicos desativados.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
function initials(name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return '—';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
function digitsOnly(v) {
|
||||
return String(v ?? '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function fmtPhone(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '');
|
||||
if (!d) return '';
|
||||
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
|
||||
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
function fmtPhoneDash(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '');
|
||||
if (!d) return '—';
|
||||
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
|
||||
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ── Modal pacientes ───────────────────────────────────────
|
||||
async function openMedicoPatientsModal(medicoRow) {
|
||||
patientsDialog.open = true;
|
||||
patientsDialog.loading = true;
|
||||
patientsDialog.error = '';
|
||||
patientsDialog.medico = medicoRow;
|
||||
patientsDialog.items = [];
|
||||
patientsDialog.search = '';
|
||||
try {
|
||||
patientsDialog.items = await fetchPatientsByMedicoNome(medicoRow.nome);
|
||||
} catch (err) {
|
||||
patientsDialog.error = humanizeError(err);
|
||||
} finally {
|
||||
patientsDialog.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const editPatientId = ref(null);
|
||||
const editPatientDialog = ref(false);
|
||||
function abrirPaciente(patient) {
|
||||
if (!patient?.id) return;
|
||||
editPatientId.value = String(patient.id);
|
||||
editPatientDialog.value = true;
|
||||
}
|
||||
watch(editPatientDialog, (isOpen) => {
|
||||
if (!isOpen) editPatientId.value = null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting; },
|
||||
{ threshold: 0, rootMargin }
|
||||
);
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
|
||||
fetchAll();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect(); });
|
||||
|
||||
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000;
|
||||
function isRecent(row) {
|
||||
if (!row?.created_at) return false;
|
||||
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PatientCadastroDialog v-model="editPatientDialog" :patient-id="editPatientId" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
ref="headerEl"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-teal-400/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-heart text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Médicos & Referências</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Gerencie os profissionais de referência que encaminham seus pacientes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca desktop -->
|
||||
<div class="hidden xl:flex flex-1 min-w-0 mx-2">
|
||||
<div class="w-64">
|
||||
<FloatLabel variant="on">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText id="medSearch" v-model="filters.global.value" class="w-full" :disabled="loading" />
|
||||
</IconField>
|
||||
<label for="medSearch">Buscar médico...</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button v-if="selectedMedicos?.length" label="Desativar selecionados" icon="pi pi-trash" severity="danger" outlined class="rounded-full" @click="confirmDeleteSelected" />
|
||||
<Button label="Novo médico" icon="pi pi-plus" class="rounded-full" @click="openCreate" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchDlgOpen = true" />
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dialog busca mobile -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Buscar médico" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome, CRM, especialidade..." autofocus />
|
||||
<Button v-if="filters.global.value" icon="pi pi-times" severity="secondary" @click="filters.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<template v-if="loading">
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||
:class="{
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
:class="{
|
||||
'text-green-500': s.cls === 'qs-ok',
|
||||
'text-[var(--text-color)]': !s.cls
|
||||
}"
|
||||
>
|
||||
{{ s.value }}
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
CONTEÚDO: tabela (esq.) + painel lateral (dir.)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
|
||||
<!-- ── TABELA ──────────────────────────────────────── -->
|
||||
<div class="w-full lg:flex-1 min-w-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Cabeçalho da seção -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-[1rem]">Lista de médicos</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-teal-500 text-white text-[1rem] font-bold">
|
||||
{{ medicos.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
ref="dt"
|
||||
v-model:selection="selectedMedicos"
|
||||
:value="medicos"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[5, 10, 25]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
:filters="filters"
|
||||
filterDisplay="menu"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} médicos"
|
||||
class="med-datatable"
|
||||
:rowClass="(r) => (isRecent(r) ? 'row-new-highlight' : '')"
|
||||
>
|
||||
<!-- Seleção -->
|
||||
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
|
||||
<template #body="{ data }">
|
||||
<Checkbox :binary="true" :modelValue="isSelected(data)" @update:modelValue="toggleRowSelection(data, $event)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.7rem] text-teal-700 shrink-0">
|
||||
{{ initials(data.nome) }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">Dr(a). {{ data.nome }}</div>
|
||||
<div v-if="data.crm" class="text-[0.72rem] text-[var(--text-color-secondary)]">CRM {{ data.crm }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="especialidade" header="Especialidade" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.especialidade" :value="data.especialidade" severity="info" />
|
||||
<span v-else class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Contato" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div v-if="data.telefone_profissional" class="flex items-center gap-1 text-[0.78rem]">
|
||||
<i class="pi pi-phone text-[0.65rem] text-teal-500" />
|
||||
<span>{{ fmtPhoneDash(data.telefone_profissional) }}</span>
|
||||
</div>
|
||||
<div v-if="data.email" class="flex items-center gap-1 text-[0.78rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-envelope text-[0.65rem]" />
|
||||
<span class="truncate max-w-[160px]">{{ data.email }}</span>
|
||||
</div>
|
||||
<span v-if="!data.telefone_profissional && !data.email" class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Local" style="min-width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-0.5 text-[0.78rem]">
|
||||
<div v-if="data.clinica" class="font-medium truncate max-w-[160px]">{{ data.clinica }}</div>
|
||||
<div v-if="data.cidade" class="text-[var(--text-color-secondary)]">
|
||||
{{ data.cidade }}<template v-if="data.estado">/{{ data.estado }}</template>
|
||||
</div>
|
||||
<span v-if="!data.clinica && !data.cidade" class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-semibold text-[var(--text-color)]">{{ Number(data.patients_count ?? 0) }}</span>
|
||||
<span class="text-[var(--text-color-secondary)] opacity-60 text-[0.73rem]">paciente(s)</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :exportable="false" header="Ações" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1.5 justify-end">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar'" @click="openEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined rounded size="small" v-tooltip.top="'Desativar'" @click="confirmDeleteOne(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Nenhum médico encontrado</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Tente limpar o filtro ou cadastre um novo médico.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtro" @click="filters.global.value = null" />
|
||||
<Button icon="pi pi-plus" label="Novo médico" @click="openCreate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: médicos com pacientes ─────────── -->
|
||||
<div class="w-full lg:w-[272px] lg:shrink-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Header do painel -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-users text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Pacientes por médico</span>
|
||||
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Médicos com encaminhamentos</span>
|
||||
</div>
|
||||
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-teal-500 text-white text-[0.65rem] font-bold shrink-0">{{ cards.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-2 p-3">
|
||||
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-heart text-2xl opacity-20" />
|
||||
<div class="font-semibold text-[0.8rem]">Nenhum encaminhamento</div>
|
||||
<div class="text-[0.72rem] opacity-70 leading-relaxed">Quando um médico tiver pacientes encaminhados, ele aparecerá aqui.</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de médicos com pacientes -->
|
||||
<div v-else class="flex flex-col max-h-[480px] overflow-y-auto divide-y divide-[var(--surface-border,#f1f5f9)]">
|
||||
<button
|
||||
v-for="m in cards"
|
||||
:key="m.id"
|
||||
class="flex items-center gap-2.5 px-3.5 py-2.5 text-left w-full bg-transparent border-none hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100 cursor-pointer group"
|
||||
@click="openMedicoPatientsModal(m)"
|
||||
>
|
||||
<!-- Avatar iniciais -->
|
||||
<div class="w-7 h-7 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.6rem] text-teal-700 shrink-0 group-hover:bg-teal-200 transition-colors">
|
||||
{{ initials(m.nome) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">Dr(a). {{ m.nome }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
{{ patientsLabel(Number(m.patients_count ?? 0)) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Badge contagem -->
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] shrink-0 bg-teal-500/10 text-teal-600">
|
||||
{{ Number(m.patients_count ?? 0) }}
|
||||
</span>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-100 group-hover:text-teal-600 transition-all duration-150 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<div v-if="cards.length" class="px-3.5 py-2 text-[1rem] text-[var(--text-color-secondary)] opacity-50 border-t border-[var(--surface-border,#f1f5f9)] text-center">
|
||||
Clique para ver os pacientes encaminhados
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Criar / Editar médico
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!dlg.saving"
|
||||
:dismissableMask="!dlg.saving"
|
||||
maximizable
|
||||
class="w-[96vw] max-w-2xl"
|
||||
: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-4' },
|
||||
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">
|
||||
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-teal-100 text-teal-600 text-[0.8rem] shrink-0">
|
||||
<i class="pi pi-heart" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ dlg.mode === 'create' ? 'Novo médico' : `Editar — Dr(a). ${dlg.nome || ''}` }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ dlg.mode === 'create' ? 'Cadastrar profissional de referência' : 'Atualizar dados do profissional' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3.5">
|
||||
<!-- Nome + CRM -->
|
||||
<div class="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_150px]">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText id="dlg_nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving" @keydown.enter.prevent="saveDialog" />
|
||||
</IconField>
|
||||
<label for="dlg_nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_crm" v-model="dlg.crm" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_crm">CRM (ex: 123456/SP)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="dlg_esp"
|
||||
v-model="dlg.especialidade"
|
||||
:options="especialidadesOpts"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
filter
|
||||
filterPlaceholder="Buscar especialidade..."
|
||||
:disabled="dlg.saving"
|
||||
/>
|
||||
<label for="dlg_esp">Especialidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade "Outra" -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
leave-active-class="transition-all duration-100 ease-in"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div v-if="dlg.especialidade === '__outra__'">
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_esp_outra" v-model="dlg.especialidade_outra" class="w-full" variant="filled" placeholder="Descreva a especialidade" :disabled="dlg.saving" />
|
||||
<label for="dlg_esp_outra">Qual especialidade? *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Divider contatos -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Contatos</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50" />
|
||||
</div>
|
||||
|
||||
<!-- Telefone profissional -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_prof" v-model="dlg.telefone_profissional" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_prof">Telefone profissional</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Consultório ou clínica.</div>
|
||||
</div>
|
||||
|
||||
<!-- Telefone pessoal -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_pes" v-model="dlg.telefone_pessoal" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_pes">Telefone pessoal / WhatsApp</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Pessoal / WhatsApp.</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-envelope" />
|
||||
<InputText id="dlg_email" v-model="dlg.email" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_email">E-mail profissional</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Divider localização -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Localização</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50" />
|
||||
</div>
|
||||
|
||||
<!-- Clínica -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-building" />
|
||||
<InputText id="dlg_clinica" v-model="dlg.clinica" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_clinica">Clínica / Hospital</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Cidade + UF -->
|
||||
<div class="grid grid-cols-[1fr_90px] gap-3">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-map-marker" />
|
||||
<InputText id="dlg_cidade" v-model="dlg.cidade" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_cidade">Cidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_uf" v-model="dlg.estado" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_uf">UF</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea id="dlg_obs" v-model="dlg.observacoes" rows="2" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_obs">Observações internas</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Ex: aceita WhatsApp, convênios atendidos, melhor horário.</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="dlg.error" class="flex items-start gap-1.5 text-[0.82rem] text-red-500 font-medium">
|
||||
<i class="pi pi-exclamation-circle mt-0.5 shrink-0" /> {{ dlg.error }}
|
||||
</div>
|
||||
</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="dlg.saving" @click="dlg.open = false" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Salvar médico' : 'Salvar alterações'"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
@click="saveDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Pacientes do médico
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="patientsDialog.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '860px', maxWidth: '95vw' }"
|
||||
:pt="{
|
||||
root: { style: 'border: 4px solid #14b8a6' },
|
||||
header: { style: 'border-bottom: 1px solid rgba(20,184,166,0.19)' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base shrink-0 bg-teal-500">
|
||||
{{ initials(patientsDialog.medico?.nome) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[1rem] font-bold text-teal-600">Dr(a). {{ patientsDialog.medico?.nome }}</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||||
<template v-if="patientsDialog.medico?.especialidade">{{ patientsDialog.medico.especialidade }} · </template>
|
||||
{{ patientsDialog.items.length }} paciente{{ patientsDialog.items.length !== 1 ? 's' : '' }} encaminhado{{ patientsDialog.items.length !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Busca + contador -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<IconField class="w-full sm:w-72">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText v-model="patientsDialog.search" placeholder="Buscar paciente..." class="w-full" :disabled="patientsDialog.loading" />
|
||||
</IconField>
|
||||
<span v-if="!patientsDialog.loading" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-teal-500/10 text-teal-600">
|
||||
{{ patientsDialog.items.length }} paciente(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4 text-teal-600"><i class="pi pi-spin pi-spinner" /> Carregando...</div>
|
||||
|
||||
<Message v-else-if="patientsDialog.error" severity="error">{{ patientsDialog.error }}</Message>
|
||||
|
||||
<div v-else>
|
||||
<!-- Empty -->
|
||||
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encaminhado</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Associe pacientes a este médico no cadastro de pacientes.</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<DataTable v-else :value="patientsDialogFiltered" dataKey="id" stripedRows responsiveLayout="scroll" paginator :rows="8" :rowsPerPageOptions="[8, 15, 30]">
|
||||
<Column header="Paciente" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
|
||||
<Avatar v-else :label="initials(data.full_name)" shape="circle" style="background: rgba(20,184,166,0.15); color: #14b8a6" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.full_name }}</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="min-width: 11rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneDash(data.phone) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ação" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined class="!border-teal-500 !text-teal-600" @click="abrirPaciente(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-8 text-center">
|
||||
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
|
||||
<div class="font-semibold text-[1rem]">Nenhum resultado</div>
|
||||
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde à busca.</div>
|
||||
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="patientsDialog.search = ''" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" outlined class="rounded-full !border-teal-500 !text-teal-600" @click="patientsDialog.open = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/PatientsDetailPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
// ── DADOS MOCKADOS ──────────────────────────────────────────────
|
||||
|
||||
const patient = ref({
|
||||
nome_completo: 'Mariana Lima',
|
||||
nome_social: null,
|
||||
pronomes: 'ela/dela',
|
||||
data_nascimento: '1992-06-14',
|
||||
cpf: '12345678990',
|
||||
genero: 'Feminino',
|
||||
estado_civil: 'Solteira',
|
||||
escolaridade: 'Superior completo',
|
||||
profissao: 'Desenvolvedora',
|
||||
etnia: null,
|
||||
naturalidade: 'São Carlos',
|
||||
telefone: '16991234567',
|
||||
email_principal: 'mariana@email.com',
|
||||
canal_preferido: 'WhatsApp',
|
||||
horario_contato: '08h–18h',
|
||||
cep: '13560-000',
|
||||
cidade: 'São Carlos',
|
||||
estado: 'SP',
|
||||
status: 'Ativa',
|
||||
convenio: 'Unimed',
|
||||
patient_scope: 'Clínica',
|
||||
risco_elevado: true,
|
||||
onde_nos_conheceu: 'Indicação',
|
||||
encaminhado_por: 'Dr. Roberto (psiq.)',
|
||||
motivo_saida: null,
|
||||
avatar_url: null,
|
||||
})
|
||||
|
||||
const tags = ref([
|
||||
{ id: '1', name: 'Ansiedade', color: '#8B5CF6' },
|
||||
{ id: '2', name: 'TCC', color: '#10B981' },
|
||||
])
|
||||
|
||||
const metricas = ref({
|
||||
total_sessoes: 47,
|
||||
comparecimento_pct: 92,
|
||||
ltv_total: 8460,
|
||||
dias_ultima_sessao: 18,
|
||||
})
|
||||
|
||||
const contatos = ref([
|
||||
{
|
||||
id: '1', nome: 'Maria Lima', relacao: 'mãe',
|
||||
telefone: '16988880001', email: 'maria@email.com', is_primario: true,
|
||||
},
|
||||
{
|
||||
id: '2', nome: 'Dr. Roberto Oliveira', relacao: 'psiquiatra',
|
||||
telefone: '1633221100', email: null, crm: 'CRM 54321', is_primario: false,
|
||||
},
|
||||
])
|
||||
|
||||
const engajamento = ref({
|
||||
comparecimento_pct: 92,
|
||||
pagamentos_em_dia_pct: 100,
|
||||
tarefas_concluidas_pct: 60,
|
||||
score_geral: 84,
|
||||
em_tratamento_meses: 14,
|
||||
proxima_sessao: '2025-03-27T14:00:00',
|
||||
})
|
||||
|
||||
const timeline = ref([
|
||||
{ id: '1', titulo: 'Risco elevado sinalizado', subtitulo: 'Atenção', data: '2025-03-12', autor: 'Dra. Ana Lima', cor: '#EF4444' },
|
||||
{ id: '2', titulo: 'GAD-7 respondido · Score 12 (ansiedade moderada)', data: '2025-03-10', canal: 'via portal', cor: '#10B981' },
|
||||
{ id: '3', titulo: 'TCLE assinado digitalmente', data: '2024-01-02', canal: 'via portal', cor: '#3B82F6' },
|
||||
{ id: '4', titulo: 'Primeira sessão realizada', data: '2024-01-15', canal: 'presencial', cor: '#10B981' },
|
||||
])
|
||||
|
||||
// ── NAVEGAÇÃO ────────────────────────────────────────────────────
|
||||
const activeTab = ref('perfil')
|
||||
const tabs = [
|
||||
{ key: 'perfil', label: 'Perfil' },
|
||||
{ key: 'prontuario', label: 'Prontuário' },
|
||||
{ key: 'agenda', label: 'Agenda' },
|
||||
{ key: 'financeiro', label: 'Financeiro' },
|
||||
{ key: 'documentos', label: 'Documentos' },
|
||||
]
|
||||
|
||||
const sideNavItems = [
|
||||
{ key: 'dados', label: 'Dados pessoais', icon: 'pi pi-user' },
|
||||
{ key: 'contato', label: 'Contato & origem', icon: 'pi pi-phone' },
|
||||
{ key: 'rede', label: 'Rede de suporte', icon: 'pi pi-users' },
|
||||
{ key: 'engajamento', label: 'Engajamento', icon: 'pi pi-chart-bar' },
|
||||
{ key: 'timeline', label: 'Linha do tempo', icon: 'pi pi-history' },
|
||||
]
|
||||
const activeSideNav = ref('dados')
|
||||
|
||||
const isCompact = ref(false)
|
||||
let mql = null, mqlHandler = null
|
||||
function syncCompact() { isCompact.value = !!mql?.matches }
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia('(max-width: 1023px)')
|
||||
mqlHandler = () => syncCompact()
|
||||
mql.addEventListener?.('change', mqlHandler)
|
||||
mql.addListener?.(mqlHandler)
|
||||
syncCompact()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener?.('change', mqlHandler)
|
||||
mql?.removeListener?.(mqlHandler)
|
||||
})
|
||||
|
||||
function scrollToSection(key) {
|
||||
activeSideNav.value = key
|
||||
const el = document.getElementById(`section-${key}`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
// ── FORMATADORES ─────────────────────────────────────────────────
|
||||
function parseDateLoose(v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const d = new Date(s.slice(0, 10))
|
||||
return isNaN(d) ? null : d
|
||||
}
|
||||
const d = new Date(s)
|
||||
return isNaN(d) ? null : d
|
||||
}
|
||||
|
||||
function calcAge(v) {
|
||||
const d = parseDateLoose(v)
|
||||
if (!d) return null
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - d.getFullYear()
|
||||
const m = now.getMonth() - d.getMonth()
|
||||
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--
|
||||
return age
|
||||
}
|
||||
|
||||
function fmtDateBR(v) {
|
||||
const d = parseDateLoose(v)
|
||||
if (!d) return '—'
|
||||
return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`
|
||||
}
|
||||
|
||||
function fmtPhone(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '')
|
||||
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7)}`
|
||||
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6)}`
|
||||
return v || '—'
|
||||
}
|
||||
|
||||
function maskCPF(v) {
|
||||
if (!v) return '—'
|
||||
const d = String(v).replace(/\D/g, '')
|
||||
return `•••${d.slice(3,6)}••••${d.slice(9)}`
|
||||
}
|
||||
|
||||
function fmtCurrency(v) {
|
||||
return `R$ ${Number(v).toLocaleString('pt-BR')}`
|
||||
}
|
||||
|
||||
function fmtProximaSessao(iso) {
|
||||
if (!iso) return '—'
|
||||
const dt = new Date(iso)
|
||||
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')} às ${String(dt.getHours()).padStart(2,'0')}h`
|
||||
}
|
||||
|
||||
const ageLabel = computed(() => calcAge(patient.value.data_nascimento))
|
||||
const birthLabel = computed(() => {
|
||||
const age = calcAge(patient.value.data_nascimento)
|
||||
return `${fmtDateBR(patient.value.data_nascimento)}${age != null ? ` (${age} a)` : ''}`
|
||||
})
|
||||
|
||||
function nameInitials(name) {
|
||||
if (!name) return '?'
|
||||
return String(name).split(' ').filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('')
|
||||
}
|
||||
const initials = computed(() => nameInitials(patient.value.nome_completo))
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = String(hex ?? '').replace('#','').trim()
|
||||
if (h.length !== 6 && h.length !== 3) return null
|
||||
const full = h.length === 3 ? h.split('').map(c=>c+c).join('') : h
|
||||
return { r: parseInt(full.slice(0,2),16), g: parseInt(full.slice(2,4),16), b: parseInt(full.slice(4,6),16) }
|
||||
}
|
||||
function bestTextColor(hex) {
|
||||
const rgb = hexToRgb(hex)
|
||||
if (!rgb) return '#0f172a'
|
||||
const lum = 0.2126*(rgb.r/255) + 0.7152*(rgb.g/255) + 0.0722*(rgb.b/255)
|
||||
return lum < 0.45 ? '#ffffff' : '#0f172a'
|
||||
}
|
||||
function tagStyle(t) {
|
||||
const bg = t?.color || t?.cor || ''
|
||||
return bg ? { backgroundColor: bg, color: bestTextColor(bg) } : {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ── BARRA SUPERIOR ───────────────────────────────────────── -->
|
||||
<div class="sticky top-0 z-20 flex items-center justify-between
|
||||
px-4 py-2.5 bg-[var(--surface-0)]
|
||||
border-b border-[var(--surface-border)]">
|
||||
<Button icon="pi pi-arrow-left" label="Pacientes"
|
||||
severity="secondary" text size="small" />
|
||||
<div class="flex gap-2">
|
||||
<Button label="Editar" outlined size="small" />
|
||||
<Button label="+ Sessão" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── LAYOUT PRINCIPAL ─────────────────────────────────────── -->
|
||||
<div class="min-h-screen bg-[var(--surface-ground)]">
|
||||
<div class="max-w-6xl mx-auto px-4 py-5">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 items-start">
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SIDEBAR ESQUERDA
|
||||
════════════════════════════════════════════════ -->
|
||||
<aside class="lg:sticky lg:top-[57px] space-y-3">
|
||||
|
||||
<!-- Bloco avatar + nome + badges + métricas -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)]
|
||||
bg-[var(--surface-card)] p-4 shadow-sm">
|
||||
|
||||
<div class="flex flex-col items-center text-center gap-2.5">
|
||||
<!-- Avatar ou iniciais -->
|
||||
<div v-if="patient.avatar_url"
|
||||
class="w-16 h-16 rounded-full overflow-hidden">
|
||||
<img :src="patient.avatar_url" class="w-full h-full object-cover" alt="avatar" />
|
||||
</div>
|
||||
<div v-else
|
||||
class="w-16 h-16 rounded-full bg-indigo-100
|
||||
flex items-center justify-center">
|
||||
<span class="text-xl font-bold text-indigo-700 tracking-tight">{{ initials }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Nome e sub-linha -->
|
||||
<div>
|
||||
<p class="text-sm font-bold text-[var(--text-color)] leading-tight">
|
||||
{{ patient.nome_completo }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
{{ ageLabel }} anos · {{ patient.pronomes }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ patient.naturalidade }}, {{ patient.estado }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status + convenio + scope -->
|
||||
<div class="flex flex-wrap justify-center gap-1">
|
||||
<Tag value="Ativa" severity="success" class="!text-[0.7rem]" />
|
||||
<Tag :value="patient.convenio" severity="info" class="!text-[0.7rem]" />
|
||||
<Tag :value="patient.patient_scope" severity="secondary" class="!text-[0.7rem]" />
|
||||
</div>
|
||||
|
||||
<!-- Tags com cor -->
|
||||
<div class="flex flex-wrap justify-center gap-1">
|
||||
<span v-for="tag in tags" :key="tag.id"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.7rem] font-medium"
|
||||
:style="tagStyle(tag)">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="!my-3" />
|
||||
|
||||
<!-- Métricas 2x2 -->
|
||||
<div class="grid grid-cols-2 gap-3 text-center">
|
||||
<div>
|
||||
<p class="text-xl font-bold text-[var(--text-color)]">{{ metricas.total_sessoes }}</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Sessões</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-emerald-600">{{ metricas.comparecimento_pct }}%</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Comparec.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base font-bold text-[var(--text-color)]">{{ fmtCurrency(metricas.ltv_total) }}</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">LTV total</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-amber-500">{{ metricas.dias_ultima_sessao }}d</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Últ. sessão</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav lateral (desktop + aba perfil) -->
|
||||
<div v-if="!isCompact && activeTab === 'perfil'"
|
||||
class="rounded-xl border border-[var(--surface-border)]
|
||||
bg-[var(--surface-card)] p-2 shadow-sm">
|
||||
<button
|
||||
v-for="item in sideNavItems" :key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2
|
||||
text-left text-sm border transition-colors duration-100"
|
||||
:class="activeSideNav === item.key
|
||||
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
|
||||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
|
||||
@click="scrollToSection(item.key)"
|
||||
>
|
||||
<i :class="item.icon" class="text-sm opacity-60 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
CONTEÚDO DIREITA
|
||||
════════════════════════════════════════════════ -->
|
||||
<div class="min-w-0 space-y-4">
|
||||
|
||||
<!-- Banner risco elevado -->
|
||||
<div v-if="patient.risco_elevado"
|
||||
class="flex items-start gap-3 rounded-xl border border-red-200
|
||||
bg-red-50 px-4 py-3">
|
||||
<i class="pi pi-circle-fill text-red-500 mt-0.5 text-[0.5rem]" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-700">
|
||||
Atenção — paciente com risco elevado sinalizado
|
||||
</p>
|
||||
<p class="text-xs text-red-500 mt-0.5">
|
||||
Ideação passiva relatada em 12/03 · Sinalizado por Dra. Ana Lima
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL COM TABS ─────────────────────────── -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)]
|
||||
bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-[var(--surface-border)] overflow-x-auto">
|
||||
<button
|
||||
v-for="tab in tabs" :key="tab.key"
|
||||
type="button"
|
||||
class="shrink-0 px-5 py-3 text-sm font-medium border-b-2
|
||||
transition-colors duration-100 whitespace-nowrap"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-[var(--primary-color)] text-[var(--primary-color)]'
|
||||
: 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════ ABA PERFIL ════════════════════════════ -->
|
||||
<div v-if="activeTab === 'perfil'" class="p-4 space-y-4">
|
||||
|
||||
<!-- DADOS PESSOAIS + CONTATO/ORIGEM -->
|
||||
<div id="section-dados" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Dados pessoais -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
|
||||
DADOS PESSOAIS
|
||||
</p>
|
||||
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome completo</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.nome_completo }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome social</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">—</span>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Pronomes</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
{{ patient.pronomes }}
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Data de nascimento</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ birthLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right font-mono">{{ maskCPF(patient.cpf) }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Gênero</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.genero }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Estado civil</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.estado_civil }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Escolaridade</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.escolaridade }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Profissão</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.profissao }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Etnia</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
<span class="italic text-[var(--text-color-secondary)]">Não informado</span>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coluna direita: Contato + Origem -->
|
||||
<div id="section-contato" class="space-y-4">
|
||||
|
||||
<!-- Contato -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
|
||||
CONTATO
|
||||
</p>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">WhatsApp</span>
|
||||
<a :href="`tel:${patient.telefone}`"
|
||||
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline">
|
||||
{{ fmtPhone(patient.telefone) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Email</span>
|
||||
<a :href="`mailto:${patient.email_principal}`"
|
||||
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[180px] inline-block">
|
||||
{{ patient.email_principal }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Canal preferido</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
{{ patient.canal_preferido }}
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Horário de contato</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
{{ patient.horario_contato }}
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CEP</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
|
||||
{{ patient.cep }} · {{ patient.cidade }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Origem -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
|
||||
ORIGEM
|
||||
</p>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Como chegou</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.onde_nos_conheceu }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Encaminhado por</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.encaminhado_por }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Método de pag.</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
PIX
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Motivo de saída</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">—</span>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REDE DE SUPORTE + ENGAJAMENTO -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Contatos & rede -->
|
||||
<div id="section-rede"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
|
||||
CONTATOS & REDE DE SUPORTE
|
||||
</p>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="c in contatos" :key="c.id"
|
||||
class="flex items-start gap-3 rounded-lg border
|
||||
border-[var(--surface-border)] p-3
|
||||
bg-[var(--surface-ground)]">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
|
||||
<span class="text-[0.65rem] font-bold text-indigo-700">{{ nameInitials(c.nome) }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-[0.82rem] font-semibold text-[var(--text-color)]">{{ c.nome }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)]">· {{ c.relacao }}</span>
|
||||
<Tag v-if="c.is_primario" value="emergência" severity="danger"
|
||||
class="!text-[0.65rem] !py-0 !px-1.5 shrink-0" />
|
||||
</div>
|
||||
<p class="text-[0.76rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||
<a :href="`tel:${c.telefone}`" class="text-[var(--primary-color)] hover:underline">{{ fmtPhone(c.telefone) }}</a>
|
||||
<template v-if="c.email"> · {{ c.email }}</template>
|
||||
<template v-if="c.crm"> · {{ c.crm }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="mt-3 w-full flex items-center gap-2.5 px-3 py-2 rounded-lg
|
||||
border border-dashed border-[var(--surface-border)]
|
||||
text-[0.82rem] text-[var(--text-color-secondary)]
|
||||
hover:bg-[var(--surface-ground)] transition-colors">
|
||||
<span class="w-7 h-7 rounded-full bg-[var(--surface-ground)] border border-[var(--surface-border)] flex items-center justify-center">
|
||||
<i class="pi pi-plus text-[0.65rem]" />
|
||||
</span>
|
||||
Adicionar contato
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Engajamento -->
|
||||
<div id="section-engajamento"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
|
||||
ENGAJAMENTO
|
||||
</p>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">Comparecimento</span>
|
||||
<span class="font-semibold text-emerald-600">{{ engajamento.comparecimento_pct }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="engajamento.comparecimento_pct" :showValue="false" class="progress-success" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">Pagamentos em dia</span>
|
||||
<span class="font-semibold text-emerald-600">{{ engajamento.pagamentos_em_dia_pct }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="engajamento.pagamentos_em_dia_pct" :showValue="false" class="progress-success" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">Tarefas concluídas</span>
|
||||
<span class="font-semibold text-amber-500">{{ engajamento.tarefas_concluidas_pct }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="engajamento.tarefas_concluidas_pct" :showValue="false" class="progress-warning" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="!my-3" />
|
||||
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Score geral</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
|
||||
<span class="text-lg font-bold">{{ engajamento.score_geral }}</span> / 100
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Em tratamento há</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ engajamento.em_tratamento_meses }} meses</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Próxima sessão</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ fmtProximaSessao(engajamento.proxima_sessao) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- LINHA DO TEMPO -->
|
||||
<div id="section-timeline"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
|
||||
LINHA DO TEMPO
|
||||
</p>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0">
|
||||
<div v-for="(item, idx) in timeline" :key="item.id" class="flex gap-4">
|
||||
<!-- Dot + linha vertical -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-3 h-3 rounded-full mt-1 shrink-0 ring-2 ring-[var(--surface-card)] shadow-sm"
|
||||
:style="{ backgroundColor: item.cor }" />
|
||||
<div v-if="idx < timeline.length - 1"
|
||||
class="w-px flex-1 bg-[var(--surface-border)] my-1" />
|
||||
</div>
|
||||
<!-- Conteúdo -->
|
||||
<div class="pb-5 min-w-0">
|
||||
<p class="text-[0.85rem] font-semibold text-[var(--text-color)] leading-snug">
|
||||
{{ item.titulo }}
|
||||
<span v-if="item.subtitulo" class="font-normal text-[var(--text-color-secondary)]"> · {{ item.subtitulo }}</span>
|
||||
</p>
|
||||
<p class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||
{{ fmtDateBR(item.data) }}
|
||||
<template v-if="item.autor"> · {{ item.autor }}</template>
|
||||
<template v-else-if="item.canal"> · {{ item.canal }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- ── FIM ABA PERFIL ── -->
|
||||
|
||||
<!-- Placeholder outras abas -->
|
||||
<div v-if="activeTab !== 'perfil'" class="p-10 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-clock text-4xl mb-3 block opacity-20" />
|
||||
<p class="text-sm">Em breve</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /painel tabs -->
|
||||
|
||||
</div><!-- /conteúdo direita -->
|
||||
</div><!-- /grid -->
|
||||
</div><!-- /max-w -->
|
||||
</div><!-- /wrapper -->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.progress-success .p-progressbar-value) { background: #22c55e; }
|
||||
:deep(.progress-warning .p-progressbar-value) { background: #f59e0b; }
|
||||
:deep(.p-progressbar) {
|
||||
height: 0.45rem;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.p-progressbar-value) { border-radius: 9999px; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2027
-3253
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@ const presetModel = computed({
|
||||
|
||||
applyThemeEngine(layoutConfig);
|
||||
queuePatch?.({ preset: val });
|
||||
saveThemeToStorage();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,11 +69,23 @@ const menuModeModel = computed({
|
||||
}
|
||||
});
|
||||
|
||||
function saveThemeToStorage() {
|
||||
try {
|
||||
localStorage.setItem('ui_theme_config', JSON.stringify({
|
||||
preset: layoutConfig.preset,
|
||||
primary: layoutConfig.primary,
|
||||
surface: layoutConfig.surface,
|
||||
menuMode: layoutConfig.menuMode
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updateColors(type, item) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = item.name;
|
||||
applyThemeEngine(layoutConfig);
|
||||
queuePatch?.({ primary_color: item.name });
|
||||
saveThemeToStorage();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,6 +93,7 @@ function updateColors(type, item) {
|
||||
layoutConfig.surface = item.name;
|
||||
applyThemeEngine(layoutConfig);
|
||||
queuePatch?.({ surface_color: item.name });
|
||||
saveThemeToStorage();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<template>
|
||||
<div class="layout-footer">
|
||||
SAKAI by
|
||||
Agência PSI
|
||||
<a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -176,7 +176,7 @@ onBeforeUnmount(() => {
|
||||
<style>
|
||||
/* ──────────────────────────────────────────────
|
||||
LAYOUT CLÁSSICO — ajustes globais (não scoped)
|
||||
para sobrescrever o tema PrimeVue/Sakai
|
||||
para sobrescrever o tema PrimeVue
|
||||
────────────────────────────────────────────── */
|
||||
|
||||
/* ── Global Notice Banner: variável de altura ─────────────
|
||||
|
||||
@@ -46,11 +46,24 @@ function isDarkNow() {
|
||||
return document.documentElement.classList.contains('app-dark');
|
||||
}
|
||||
|
||||
async function waitForDarkFlip(before, timeoutMs = 900) {
|
||||
const start = performance.now();
|
||||
while (performance.now() - start < timeoutMs) {
|
||||
await nextTick();
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const now = isDarkNow();
|
||||
if (now !== before) return now;
|
||||
}
|
||||
return isDarkNow();
|
||||
}
|
||||
|
||||
async function toggleDarkAndPersist() {
|
||||
try {
|
||||
const before = isDarkNow();
|
||||
toggleDarkMode();
|
||||
await nextTick();
|
||||
const theme_mode = isDarkNow() ? 'dark' : 'light';
|
||||
const after = await waitForDarkFlip(before);
|
||||
const theme_mode = after ? 'dark' : 'light';
|
||||
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
|
||||
await queuePatch({ theme_mode }, { flushNow: true });
|
||||
} catch (e) {
|
||||
console.error('[FooterPanel][theme] falhou:', e?.message || e);
|
||||
|
||||
@@ -46,6 +46,7 @@ const emit = defineEmits(['quick-create']);
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, default: () => ({}) },
|
||||
index: { type: Number, default: 0 },
|
||||
root: { type: Boolean, default: false },
|
||||
parentPath: { type: String, default: null }
|
||||
});
|
||||
|
||||
+14
-8
@@ -95,6 +95,7 @@ const userName = computed(() => sessionUser.value?.user_metadata?.full_name || s
|
||||
|
||||
// ── Início (fixo) ────────────────────────────────────────────
|
||||
function selectHome() {
|
||||
if (layoutConfig.railOpenMode === 'hover') return;
|
||||
if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) {
|
||||
layoutState.railPanelOpen = false;
|
||||
} else {
|
||||
@@ -107,6 +108,7 @@ const isHomeActive = computed(() => layoutState.railSectionKey === '__home__' &&
|
||||
|
||||
// ── Seleção de seção ─────────────────────────────────────────
|
||||
function selectSection(section) {
|
||||
if (layoutConfig.railOpenMode === 'hover') return;
|
||||
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
|
||||
layoutState.railPanelOpen = false;
|
||||
} else {
|
||||
@@ -115,13 +117,21 @@ function selectSection(section) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica recursivamente se alguma rota do grupo está ativa
|
||||
function _matchesActive(items, active) {
|
||||
return items.some((i) => {
|
||||
const p = typeof i.to === 'string' ? i.to : '';
|
||||
if (p && active.startsWith(p)) return true;
|
||||
if (Array.isArray(i.items) && i.items.length) return _matchesActive(i.items, active);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function isActiveSectionOrChild(section) {
|
||||
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true;
|
||||
const active = String(layoutState.activePath || '');
|
||||
return section.items.some((i) => {
|
||||
const p = typeof i.to === 'string' ? i.to : '';
|
||||
return p && active.startsWith(p);
|
||||
});
|
||||
if (!active) return false;
|
||||
return _matchesActive(section.items, active);
|
||||
}
|
||||
|
||||
// ── Menu do usuário (rodapé) ─────────────────────────────────
|
||||
@@ -144,7 +154,6 @@ function toggleUserMenu(e) {
|
||||
<button
|
||||
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
|
||||
:class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
|
||||
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
|
||||
aria-label="Início"
|
||||
@click="selectHome"
|
||||
@mouseenter="onHomeHover"
|
||||
@@ -157,7 +166,6 @@ function toggleUserMenu(e) {
|
||||
:key="section.key"
|
||||
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
|
||||
:class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
|
||||
v-tooltip.right="{ value: section.label, showDelay: 0 }"
|
||||
:aria-label="section.label"
|
||||
@click="selectSection(section)"
|
||||
@mouseenter="onSectionHover(section)"
|
||||
@@ -170,7 +178,6 @@ function toggleUserMenu(e) {
|
||||
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
|
||||
<button
|
||||
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
|
||||
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
|
||||
aria-label="Configurações"
|
||||
@click="$router.push('/configuracoes')"
|
||||
>
|
||||
@@ -180,7 +187,6 @@ function toggleUserMenu(e) {
|
||||
<!-- Avatar — trigger do menu de usuário -->
|
||||
<button
|
||||
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
|
||||
v-tooltip.right="{ value: userName, showDelay: 0 }"
|
||||
:aria-label="userName"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
|
||||
@@ -114,12 +114,14 @@ function onPanelMouseEnter() {
|
||||
}
|
||||
function onPanelMouseLeave() {
|
||||
if (layoutConfig.railOpenMode !== 'hover') return;
|
||||
if (popoverOpen.value) return; // popover flutuante aberto — não fechar
|
||||
scheduleRailHoverClose(200);
|
||||
}
|
||||
|
||||
// ── QuickCreate (Pacientes) ───────────────────────────────
|
||||
const createPopover = ref(null);
|
||||
const quickDialog = ref(false);
|
||||
const popoverOpen = ref(false);
|
||||
|
||||
function openQuickCreate(event, item) {
|
||||
createPopover.value?.toggle(event);
|
||||
@@ -482,7 +484,7 @@ async function goToResult(r) {
|
||||
</nav>
|
||||
|
||||
<!-- PatientCreatePopover (shared) -->
|
||||
<PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" />
|
||||
<PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" @show="popoverOpen = true" @hide="popoverOpen = false" />
|
||||
|
||||
<!-- Cadastro Rápido Dialog -->
|
||||
<ComponentCadastroRapido
|
||||
|
||||
@@ -57,6 +57,7 @@ const presetModel = computed({
|
||||
layoutConfig.preset = v;
|
||||
applyThemeEngine(layoutConfig);
|
||||
queuePatch?.({ preset: v });
|
||||
saveThemeToStorage();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,16 +69,29 @@ const menuModeModel = computed({
|
||||
}
|
||||
});
|
||||
|
||||
function saveThemeToStorage() {
|
||||
try {
|
||||
localStorage.setItem('ui_theme_config', JSON.stringify({
|
||||
preset: layoutConfig.preset,
|
||||
primary: layoutConfig.primary,
|
||||
surface: layoutConfig.surface,
|
||||
menuMode: layoutConfig.menuMode
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updateColors(type, item) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = item.name;
|
||||
applyThemeEngine(layoutConfig);
|
||||
queuePatch?.({ primary_color: item.name });
|
||||
saveThemeToStorage();
|
||||
}
|
||||
if (type === 'surface') {
|
||||
layoutConfig.surface = item.name;
|
||||
applyThemeEngine(layoutConfig);
|
||||
queuePatch?.({ surface_color: item.name });
|
||||
saveThemeToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,16 @@ async function loadAndApplyUserSettings() {
|
||||
// 3) aplica engine UMA vez
|
||||
applyThemeEngine(layoutConfig);
|
||||
|
||||
// 4) persiste no localStorage para carregamento instantâneo no próximo boot
|
||||
try {
|
||||
localStorage.setItem('ui_theme_config', JSON.stringify({
|
||||
preset: layoutConfig.preset,
|
||||
primary: layoutConfig.primary,
|
||||
surface: layoutConfig.surface,
|
||||
menuMode: layoutConfig.menuMode
|
||||
}));
|
||||
} catch {}
|
||||
|
||||
// ✅ IMPORTANTE:
|
||||
// changeMenuMode NÃO é só "setar menuMode".
|
||||
// Ele reseta estados do sidebar/overlay/mobile e previne drift.
|
||||
@@ -165,6 +175,7 @@ async function toggleDarkAndPersistSilently() {
|
||||
toggleDarkMode();
|
||||
const after = await waitForDarkFlip(before);
|
||||
const theme_mode = after ? 'dark' : 'light';
|
||||
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
|
||||
await queuePatch({ theme_mode }, { flushNow: true });
|
||||
} catch (e) {
|
||||
console.error('[Topbar][theme] falhou:', e?.message || e);
|
||||
@@ -632,7 +643,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* Hamburguer: visível apenas em ≤ xl (1280px)
|
||||
!important necessário para sobrescrever CSS do tema Sakai (.layout-menu-button) */
|
||||
!important necessário para sobrescrever CSS do tema (.layout-menu-button) */
|
||||
.rail-topbar__hamburger {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -36,12 +36,23 @@ function _loadRailOpenMode() {
|
||||
return 'hover';
|
||||
}
|
||||
|
||||
// ── resolve tema (preset/primary/surface) salvo no localStorage ─
|
||||
function _loadSavedTheme() {
|
||||
try {
|
||||
const raw = localStorage.getItem('ui_theme_config');
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
const _savedTheme = _loadSavedTheme();
|
||||
|
||||
const layoutConfig = reactive({
|
||||
preset: 'Aura',
|
||||
primary: 'emerald',
|
||||
surface: null,
|
||||
preset: _savedTheme.preset || 'Aura',
|
||||
primary: _savedTheme.primary || 'emerald',
|
||||
surface: _savedTheme.surface || null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static',
|
||||
menuMode: _savedTheme.menuMode || 'static',
|
||||
variant: _loadVariant(), // 'classic' | 'rail'
|
||||
railOpenMode: _loadRailOpenMode() // 'click' | 'hover'
|
||||
});
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted, nextTick } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import PausasChipsEditor from '@/components/agenda/PausasChipsEditor.vue';
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
@@ -1385,7 +1385,7 @@ const jornadaEndDate = computed({
|
||||
<div class="anim-child [--delay:120ms] xl:w-[42%] xl:top-4 xl:self-start">
|
||||
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
|
||||
<!-- Header do preview -->
|
||||
<div class="sticky top-0 z-10 bg-white">
|
||||
<div class="sticky top-0 z-10">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
|
||||
<div class="font-semibold text-sm">Preview da agenda</div>
|
||||
<div class="flex gap-1">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import Editor from 'primevue/editor';
|
||||
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { renderEmail, renderTemplate, generateLayoutSection } from '@/lib/email/emailTemplateService';
|
||||
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants';
|
||||
@@ -135,19 +135,6 @@ const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success'
|
||||
// ── Dialog layout (header/footer global) ──────────────────────
|
||||
const layoutDlg = ref({ open: false, saving: false });
|
||||
const layoutForm = ref({ header: defaultSection(), footer: defaultSection() });
|
||||
const headerEditorRef = ref(null);
|
||||
const footerEditorRef = ref(null);
|
||||
|
||||
const LAYOUT_OPTIONS = [
|
||||
{ value: 'logo-left', label: 'Logo à esquerda' },
|
||||
{ value: 'logo-right', label: 'Logo à direita' },
|
||||
{ value: 'logo-center', label: 'Logo centralizada' }
|
||||
];
|
||||
const TEXT_OPTIONS = [
|
||||
{ value: 'text-left', label: 'Texto à esquerda' },
|
||||
{ value: 'text-center', label: 'Texto centralizado' },
|
||||
{ value: 'text-right', label: 'Texto à direita' }
|
||||
];
|
||||
|
||||
function openLayoutDlg() {
|
||||
layoutForm.value = {
|
||||
@@ -157,10 +144,6 @@ function openLayoutDlg() {
|
||||
layoutDlg.value = { open: true, saving: false };
|
||||
}
|
||||
|
||||
function selectLayout(which, type) {
|
||||
layoutForm.value[which].layout = type;
|
||||
}
|
||||
|
||||
const headerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.header, profileLogoUrl.value, true));
|
||||
const footerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.footer, profileLogoUrl.value, false));
|
||||
|
||||
@@ -206,7 +189,6 @@ function openEdit(row) {
|
||||
subject: ov?.subject ?? row.subject,
|
||||
body_html: ov?.body_html ?? row.body_html,
|
||||
body_text: ov?.body_text ?? '',
|
||||
enabled: ov?.enabled ?? true,
|
||||
synced_version: row.version,
|
||||
variables: row.variables || {}
|
||||
};
|
||||
@@ -225,15 +207,11 @@ const formVariables = computed(() => {
|
||||
|
||||
function insertVar(varName) {
|
||||
const snippet = `{{${varName}}}`;
|
||||
const quill = editorRef.value?.quill;
|
||||
if (!quill) {
|
||||
if (editorRef.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(snippet);
|
||||
} else {
|
||||
form.value.body_html = (form.value.body_html || '') + snippet;
|
||||
return;
|
||||
}
|
||||
const range = quill.getSelection(true);
|
||||
const index = range ? range.index : quill.getLength() - 1;
|
||||
quill.insertText(index, snippet, 'user');
|
||||
quill.setSelection(index + snippet.length, 0);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -251,7 +229,7 @@ async function save() {
|
||||
subject: form.value.use_custom_subject ? form.value.subject : null,
|
||||
body_html: form.value.use_custom_body ? form.value.body_html : null,
|
||||
body_text: form.value.use_custom_body && form.value.body_text ? form.value.body_text : null,
|
||||
enabled: form.value.enabled,
|
||||
enabled: true,
|
||||
synced_version: form.value.synced_version
|
||||
};
|
||||
if (dlg.value.mode === 'create') {
|
||||
@@ -323,9 +301,19 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Filtro -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<!-- Filtro + Layout global -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Button v-for="opt in DOMAIN_OPTIONS" :key="String(opt.value)" :label="opt.label" size="small" :severity="filterDomain === opt.value ? 'primary' : 'secondary'" :outlined="filterDomain !== opt.value" @click="filterDomain = opt.value" />
|
||||
<div class="ml-auto">
|
||||
<Button
|
||||
label="Layout global"
|
||||
icon="pi pi-palette"
|
||||
size="small"
|
||||
:severity="layoutActive ? 'success' : 'secondary'"
|
||||
:outlined="!layoutActive"
|
||||
@click="openLayoutDlg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SKELETON ══════════════════════════════════════════════ -->
|
||||
@@ -394,88 +382,21 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div v-if="layoutForm.header.enabled" class="px-4 py-4 flex flex-col gap-4">
|
||||
<!-- Cards de layout -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-semibold">Com logo</p>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="opt in LAYOUT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.header.layout === opt.value }" @click="selectLayout('header', opt.value)">
|
||||
<div class="layout-card__thumb">
|
||||
<template v-if="opt.value === 'logo-left'">
|
||||
<div class="lc-logo" />
|
||||
<div class="lc-spacer" />
|
||||
<div class="lc-lines">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="opt.value === 'logo-right'">
|
||||
<div class="lc-lines">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" />
|
||||
</div>
|
||||
<div class="lc-spacer" />
|
||||
<div class="lc-logo" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="lc-center">
|
||||
<div class="lc-logo" />
|
||||
<div class="lc-line" style="width: 70%; margin-top: 5px" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="layout-card__label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-semibold mt-1">Só texto</p>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="opt in TEXT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.header.layout === opt.value }" @click="selectLayout('header', opt.value)">
|
||||
<div class="layout-card__thumb">
|
||||
<template v-if="opt.value === 'text-left'">
|
||||
<div class="lc-lines">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="opt.value === 'text-center'">
|
||||
<div class="lc-lines lc-lines--center">
|
||||
<div class="lc-line" style="width: 85%" />
|
||||
<div class="lc-line short" style="width: 55%; align-self: center" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="lc-lines lc-lines--right">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" style="align-self: flex-end" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="layout-card__label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor de texto -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-semibold">Texto</label>
|
||||
<Editor ref="headerEditorRef" v-model="layoutForm.header.content" editor-style="min-height:100px;font-size:0.85rem;">
|
||||
<template #toolbar>
|
||||
<span class="ql-formats">
|
||||
<button class="ql-bold" type="button" />
|
||||
<button class="ql-italic" type="button" />
|
||||
<button class="ql-underline" type="button" />
|
||||
</span>
|
||||
<span class="ql-formats">
|
||||
<select class="ql-color" />
|
||||
</span>
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
<!-- Editor Jodit — use os botões "▣ Logo Esq./Dir./Centro" na toolbar para inserir layouts prontos -->
|
||||
<JoditEmailEditor
|
||||
v-model="layoutForm.header.content"
|
||||
:min-height="160"
|
||||
:layout-buttons="true"
|
||||
:logo-url="profileLogoUrl"
|
||||
/>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-if="headerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
|
||||
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="headerLayoutPreview" />
|
||||
<div v-if="headerLayoutPreview" class="rounded-lg border border-(--surface-border) bg-(--surface-ground) p-3">
|
||||
<span class="text-[0.65rem] text-(--text-color-secondary) uppercase tracking-widest block mb-2">Preview</span>
|
||||
<div class="rounded border border-(--surface-border) bg-white p-3 text-sm text-gray-800 shadow-sm">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="headerLayoutPreview" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,88 +415,21 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div v-if="layoutForm.footer.enabled" class="px-4 py-4 flex flex-col gap-4">
|
||||
<!-- Cards de layout -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-semibold">Com logo</p>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="opt in LAYOUT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }" @click="selectLayout('footer', opt.value)">
|
||||
<div class="layout-card__thumb">
|
||||
<template v-if="opt.value === 'logo-left'">
|
||||
<div class="lc-logo" />
|
||||
<div class="lc-spacer" />
|
||||
<div class="lc-lines">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="opt.value === 'logo-right'">
|
||||
<div class="lc-lines">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" />
|
||||
</div>
|
||||
<div class="lc-spacer" />
|
||||
<div class="lc-logo" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="lc-center">
|
||||
<div class="lc-logo" />
|
||||
<div class="lc-line" style="width: 70%; margin-top: 5px" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="layout-card__label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-semibold mt-1">Só texto</p>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="opt in TEXT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }" @click="selectLayout('footer', opt.value)">
|
||||
<div class="layout-card__thumb">
|
||||
<template v-if="opt.value === 'text-left'">
|
||||
<div class="lc-lines">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="opt.value === 'text-center'">
|
||||
<div class="lc-lines lc-lines--center">
|
||||
<div class="lc-line" style="width: 85%" />
|
||||
<div class="lc-line short" style="width: 55%; align-self: center" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="lc-lines lc-lines--right">
|
||||
<div class="lc-line" />
|
||||
<div class="lc-line short" style="align-self: flex-end" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="layout-card__label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor de texto -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-semibold">Texto</label>
|
||||
<Editor ref="footerEditorRef" v-model="layoutForm.footer.content" editor-style="min-height:100px;font-size:0.85rem;">
|
||||
<template #toolbar>
|
||||
<span class="ql-formats">
|
||||
<button class="ql-bold" type="button" />
|
||||
<button class="ql-italic" type="button" />
|
||||
<button class="ql-underline" type="button" />
|
||||
</span>
|
||||
<span class="ql-formats">
|
||||
<select class="ql-color" />
|
||||
</span>
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
<!-- Editor Jodit — use os botões "▣ Logo Esq./Dir./Centro" na toolbar para inserir layouts prontos -->
|
||||
<JoditEmailEditor
|
||||
v-model="layoutForm.footer.content"
|
||||
:min-height="160"
|
||||
:layout-buttons="true"
|
||||
:logo-url="profileLogoUrl"
|
||||
/>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-if="footerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
|
||||
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="footerLayoutPreview" />
|
||||
<div v-if="footerLayoutPreview" class="rounded-lg border border-(--surface-border) bg-(--surface-ground) p-3">
|
||||
<span class="text-[0.65rem] text-(--text-color-secondary) uppercase tracking-widest block mb-2">Preview</span>
|
||||
<div class="rounded border border-(--surface-border) bg-white p-3 text-sm text-gray-800 shadow-sm">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="footerLayoutPreview" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -616,35 +470,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="px-4 py-3 flex flex-col gap-3">
|
||||
<template v-if="form.use_custom_body">
|
||||
<Editor ref="editorRef" v-model="form.body_html" editor-style="min-height:200px;font-size:0.85rem;">
|
||||
<template #toolbar>
|
||||
<span class="ql-formats">
|
||||
<select class="ql-header">
|
||||
<option value="1">Título</option>
|
||||
<option value="2">Subtítulo</option>
|
||||
<option selected>Normal</option>
|
||||
</select>
|
||||
</span>
|
||||
<span class="ql-formats">
|
||||
<button class="ql-bold" type="button" />
|
||||
<button class="ql-italic" type="button" />
|
||||
<button class="ql-underline" type="button" />
|
||||
</span>
|
||||
<span class="ql-formats">
|
||||
<select class="ql-align" />
|
||||
<select class="ql-color" />
|
||||
<select class="ql-background" />
|
||||
</span>
|
||||
<span class="ql-formats">
|
||||
<button class="ql-list" value="ordered" type="button" />
|
||||
<button class="ql-list" value="bullet" type="button" />
|
||||
</span>
|
||||
<span class="ql-formats">
|
||||
<button class="ql-link" type="button" />
|
||||
<button class="ql-clean" type="button" />
|
||||
</span>
|
||||
</template>
|
||||
</Editor>
|
||||
<JoditEmailEditor ref="editorRef" v-model="form.body_html" :min-height="220" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@@ -660,11 +486,6 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Override ativo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="form.enabled" inputId="sw-enabled" />
|
||||
<label for="sw-enabled" class="text-sm cursor-pointer select-none">Override ativo</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -699,104 +520,4 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout cards ───────────────────────────────────────── */
|
||||
.layout-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 8px;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.layout-card:hover {
|
||||
border-color: color-mix(in srgb, var(--primary-color, #6366f1) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card));
|
||||
}
|
||||
.layout-card--active {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 8%, var(--surface-card));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
}
|
||||
.layout-card__thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 5px;
|
||||
padding: 6px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.layout-card__label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.layout-card--active .layout-card__label {
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
/* Elementos internos dos cards */
|
||||
.lc-logo {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 35%, #e5e7eb);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
}
|
||||
.lc-spacer {
|
||||
flex: 1;
|
||||
min-width: 4px;
|
||||
}
|
||||
.lc-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.lc-lines--center {
|
||||
align-items: center;
|
||||
}
|
||||
.lc-lines--right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.lc-line {
|
||||
height: 3px;
|
||||
background: #d1d5db;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.lc-line.short {
|
||||
width: 60%;
|
||||
}
|
||||
.lc-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
}
|
||||
.lc-center .lc-logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.lc-center .lc-line {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Esconde botão de imagem do Quill em todos os editores desta página */
|
||||
:deep(.ql-image) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -378,6 +378,42 @@ function confirmRestoreTemplate(tpl) {
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ABA 2 — Emojis rápidos para o guia de formatação
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const QUICK_EMOJIS = [
|
||||
{ char: '📅', label: 'Calendário' },
|
||||
{ char: '⏰', label: 'Relógio / Lembrete' },
|
||||
{ char: '✅', label: 'Confirmado' },
|
||||
{ char: '❌', label: 'Cancelado' },
|
||||
{ char: '🔔', label: 'Notificação' },
|
||||
{ char: '💬', label: 'Mensagem' },
|
||||
{ char: '💙', label: 'Cuidado / Saúde' },
|
||||
{ char: '🌿', label: 'Bem-estar' },
|
||||
{ char: '🤝', label: 'Parceria / Encontro' },
|
||||
{ char: '📋', label: 'Formulário / Triagem' },
|
||||
{ char: '💰', label: 'Financeiro' },
|
||||
{ char: '🔗', label: 'Link' },
|
||||
{ char: '📍', label: 'Local' },
|
||||
{ char: '📞', label: 'Telefone' },
|
||||
{ char: '🏥', label: 'Clínica' },
|
||||
{ char: '🧠', label: 'Psicologia' },
|
||||
{ char: '👤', label: 'Paciente' },
|
||||
{ char: '🌟', label: 'Destaque' },
|
||||
{ char: '⚠️', label: 'Atenção' },
|
||||
{ char: '➡️', label: 'Seta / Próximo passo' },
|
||||
{ char: '🗓️', label: 'Agenda' },
|
||||
{ char: '🕐', label: 'Hora' },
|
||||
{ char: '📩', label: 'Recebido' },
|
||||
{ char: '🔄', label: 'Reagendamento' }
|
||||
];
|
||||
|
||||
function copyEmoji(char) {
|
||||
navigator.clipboard?.writeText(char).catch(() => {});
|
||||
toast.add({ severity: 'info', summary: `${char} copiado!`, life: 1500 });
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ABA 3 — Logs de envio
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -533,47 +569,152 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- ══ ABA 2 — Templates ══════════════════════════════════ -->
|
||||
<TabPanel :value="1">
|
||||
<div class="flex flex-col gap-3 pt-3">
|
||||
<!-- Skeleton loading -->
|
||||
<template v-if="templatesLoading">
|
||||
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
|
||||
<Skeleton width="10rem" height="1rem" />
|
||||
<div class="flex gap-4 pt-3 items-start">
|
||||
|
||||
<!-- ── Coluna esquerda: cards de templates (65%) ── -->
|
||||
<div class="flex flex-col gap-3 min-w-0" style="flex: 0 0 65%;">
|
||||
<!-- Skeleton loading -->
|
||||
<template v-if="templatesLoading">
|
||||
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
|
||||
<Skeleton width="10rem" height="1rem" />
|
||||
</div>
|
||||
<Skeleton width="100%" height="5rem" class="mb-2" />
|
||||
<div class="flex gap-1">
|
||||
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton width="100%" height="5rem" class="mb-2" />
|
||||
<div class="flex gap-1">
|
||||
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" />
|
||||
</template>
|
||||
|
||||
<!-- Cards de templates -->
|
||||
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||
<!-- Header do card -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-semibold text-sm">{{ tpl.label }}</span>
|
||||
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
|
||||
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cards de templates -->
|
||||
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||
<!-- Header do card -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-semibold text-sm">{{ tpl.label }}</span>
|
||||
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
|
||||
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
|
||||
</div>
|
||||
<!-- Textarea editável -->
|
||||
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
|
||||
|
||||
<!-- Textarea editável -->
|
||||
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
|
||||
|
||||
<!-- Variáveis clicáveis -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
|
||||
<!-- Variáveis clicáveis -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
|
||||
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
|
||||
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Coluna direita: guia de formatação (35%) ── -->
|
||||
<div class="flex flex-col gap-3 sticky top-4" style="flex: 0 0 35%;">
|
||||
<div class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-book text-[var(--primary-color)]" />
|
||||
<span class="font-semibold text-sm">Guia de formatação</span>
|
||||
</div>
|
||||
|
||||
<!-- Formatação oficial -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Formatação oficial</span>
|
||||
<div class="flex-1 h-px bg-[var(--surface-border)]" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">*texto*</span>
|
||||
<span class="text-xs font-bold">Negrito</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">_texto_</span>
|
||||
<span class="text-xs italic">Itálico</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">~texto~</span>
|
||||
<span class="text-xs line-through">Tachado</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">`texto`</span>
|
||||
<span class="text-xs font-mono bg-[var(--surface-ground)] px-1 rounded">Monoespaçado</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Efeitos extras Unicode -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Efeitos extras (Unicode)</span>
|
||||
<div class="flex-1 h-px bg-[var(--surface-border)]" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Negrito Unicode</span>
|
||||
<span class="text-xs">𝙝𝙤𝙡𝙖</span>
|
||||
</div>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Copie de sites de "font generator"</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Cursiva Unicode</span>
|
||||
<span class="text-xs">𝓽𝓮𝔁𝓽𝓸</span>
|
||||
</div>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Cada letra é um caractere diferente</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Small Caps</span>
|
||||
<span class="text-xs">ᴛᴇxᴛᴏ</span>
|
||||
</div>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Bom para títulos curtos</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Sublinhado</span>
|
||||
<span class="text-xs">t̲e̲x̲t̲o̲</span>
|
||||
</div>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">U+0332 após cada letra</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emojis mais usados -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Emojis mais usados</span>
|
||||
<div class="flex-1 h-px bg-[var(--surface-border)]" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="emoji in QUICK_EMOJIS"
|
||||
:key="emoji.char"
|
||||
v-tooltip.top="emoji.label"
|
||||
class="text-base leading-none p-1 rounded hover:bg-[var(--surface-hover)] transition-colors cursor-pointer border-0 bg-transparent"
|
||||
@click="copyEmoji(emoji.char)"
|
||||
>{{ emoji.char }}</button>
|
||||
</div>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Clique para copiar</span>
|
||||
</div>
|
||||
|
||||
<!-- Dica -->
|
||||
<div class="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<i class="pi pi-lightbulb text-amber-500 text-xs mt-0.5 shrink-0" />
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] m-0 leading-relaxed">
|
||||
Use <strong>*negrito*</strong> para destacar horários e datas. Evite excesso de formatação — mensagens simples têm maior taxa de leitura.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
+67
-48
@@ -1,9 +1,18 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI (OTIMIZADO)
|
||||
| Agência PSI — main.js
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
(function applyDarkModeImmediate() {
|
||||
try {
|
||||
const saved = localStorage.getItem('ui_theme_mode');
|
||||
if (saved === 'dark' || saved === 'light') {
|
||||
document.documentElement.classList.toggle('app-dark', saved === 'dark');
|
||||
}
|
||||
} catch { }
|
||||
})();
|
||||
|
||||
import { pinia } from '@/plugins/pinia';
|
||||
import router from '@/router';
|
||||
import { createApp } from 'vue';
|
||||
@@ -11,32 +20,25 @@ import App from './App.vue';
|
||||
|
||||
import { initSession, listenAuthChanges, refreshSession, setOnSignedOut } from '@/app/session';
|
||||
|
||||
// PrimeVue core
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
|
||||
// serviços (ok global)
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
|
||||
// ✅ SOMENTE COMPONENTES LEVES GLOBAIS
|
||||
import Button from 'primevue/button';
|
||||
import Divider from 'primevue/divider';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Tag from 'primevue/tag';
|
||||
import Toast from 'primevue/toast';
|
||||
|
||||
// seus componentes leves
|
||||
import AppLoadingPhrases from '@/components/ui/AppLoadingPhrases.vue';
|
||||
import LoadedPhraseBlock from '@/components/ui/LoadedPhraseBlock.vue';
|
||||
|
||||
// estilos
|
||||
import '@/assets/styles.scss';
|
||||
import '@/assets/tailwind.css';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// locale
|
||||
const ptBR = {
|
||||
firstDayOfWeek: 1,
|
||||
dayNames: ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'],
|
||||
@@ -50,29 +52,49 @@ const ptBR = {
|
||||
dateFormat: 'dd/mm/yy'
|
||||
};
|
||||
|
||||
// theme antecipado
|
||||
async function applyUserThemeEarly() {
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
const user = data?.user;
|
||||
if (!user) return;
|
||||
function syncThemeFromDB() {
|
||||
const run = async () => {
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!data?.user) return;
|
||||
|
||||
const { data: settings } = await supabase.from('user_settings').select('theme_mode').eq('user_id', user.id).maybeSingle();
|
||||
const { data: settings } = await supabase
|
||||
.from('user_settings')
|
||||
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
|
||||
.eq('user_id', data.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!settings?.theme_mode) return;
|
||||
if (!settings) return;
|
||||
|
||||
const isDark = settings.theme_mode === 'dark';
|
||||
document.documentElement.classList.toggle('app-dark', isDark);
|
||||
localStorage.setItem('ui_theme_mode', settings.theme_mode);
|
||||
} catch {}
|
||||
if (settings.theme_mode) {
|
||||
document.documentElement.classList.toggle('app-dark', settings.theme_mode === 'dark');
|
||||
localStorage.setItem('ui_theme_mode', settings.theme_mode);
|
||||
}
|
||||
|
||||
const cfg = {};
|
||||
if (settings.preset) cfg.preset = settings.preset;
|
||||
if (settings.primary_color) cfg.primary = settings.primary_color;
|
||||
if (settings.surface_color) cfg.surface = settings.surface_color;
|
||||
if (settings.menu_mode) cfg.menuMode = settings.menu_mode;
|
||||
|
||||
if (Object.keys(cfg).length) {
|
||||
try {
|
||||
const prev = JSON.parse(localStorage.getItem('ui_theme_config') || '{}');
|
||||
localStorage.setItem('ui_theme_config', JSON.stringify({ ...prev, ...cfg }));
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
};
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(run, { timeout: 4000 });
|
||||
} else {
|
||||
setTimeout(run, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// logout
|
||||
setOnSignedOut(() => {
|
||||
router.replace('/auth/login');
|
||||
});
|
||||
setOnSignedOut(() => router.replace('/auth/login'));
|
||||
|
||||
// flags
|
||||
window.__sessionRefreshing = false;
|
||||
window.__fromVisibilityRefresh = false;
|
||||
window.__appBootstrapped = false;
|
||||
@@ -84,13 +106,15 @@ document.addEventListener('visibilitychange', async () => {
|
||||
if (!window.__appBootstrapped) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastVisibilityRefreshAt < 10000) return;
|
||||
if (now - lastVisibilityRefreshAt < 10_000) return;
|
||||
if (window.__sessionRefreshing) return;
|
||||
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!data?.user) return;
|
||||
} catch {}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisibilityRefreshAt = now;
|
||||
|
||||
@@ -100,15 +124,14 @@ document.addEventListener('visibilitychange', async () => {
|
||||
|
||||
await refreshSession();
|
||||
|
||||
const path = router.currentRoute.value?.path || '';
|
||||
const isTenantArea = path.startsWith('/admin') || path.startsWith('/therapist') || path.startsWith('/saas');
|
||||
const path = router.currentRoute.value?.path ?? '';
|
||||
const isTenantArea =
|
||||
path.startsWith('/admin') ||
|
||||
path.startsWith('/therapist') ||
|
||||
path.startsWith('/saas');
|
||||
|
||||
if (isTenantArea) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('app:session-refreshed', {
|
||||
detail: { source: 'visibility' }
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent('app:session-refreshed', { detail: { source: 'visibility' } }));
|
||||
}
|
||||
} finally {
|
||||
window.__fromVisibilityRefresh = false;
|
||||
@@ -118,39 +141,35 @@ document.addEventListener('visibilitychange', async () => {
|
||||
|
||||
async function bootstrap() {
|
||||
await initSession({ initial: true });
|
||||
listenAuthChanges();
|
||||
await applyUserThemeEarly();
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
await router.isReady();
|
||||
|
||||
listenAuthChanges();
|
||||
syncThemeFromDB();
|
||||
|
||||
const { layoutConfig } = useLayout();
|
||||
|
||||
app.use(PrimeVue, {
|
||||
locale: ptBR,
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: { darkModeSelector: '.app-dark' }
|
||||
}
|
||||
theme: { options: { darkModeSelector: '.app-dark' } }
|
||||
});
|
||||
|
||||
applyThemeEngine(layoutConfig);
|
||||
|
||||
app.use(ToastService);
|
||||
app.use(ConfirmationService);
|
||||
|
||||
// ✅ globais leves
|
||||
app.component('Button', Button);
|
||||
app.component('InputText', InputText);
|
||||
app.component('Tag', Tag);
|
||||
app.component('Divider', Divider);
|
||||
app.component('Toast', Toast);
|
||||
|
||||
app.component('AppLoadingPhrases', AppLoadingPhrases);
|
||||
app.component('LoadedPhraseBlock', LoadedPhraseBlock);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
window.__appBootstrapped = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,10 @@ export default function adminMenu(ctx = {}) {
|
||||
{ label: 'Lista de Pacientes', icon: 'pi pi-fw pi-users', to: { name: 'admin-pacientes' }, quickCreate: true, quickCreateRoute: 'admin-pacientes-cadastro', quickCreateLinkTo: { name: 'admin-pacientes-link-externo' } },
|
||||
{ label: 'Grupos', icon: 'pi pi-fw pi-sitemap', to: { name: 'admin-pacientes-grupos' } },
|
||||
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
|
||||
{ label: 'Médicos & Referências', icon: 'pi pi-fw pi-heart', to: { name: 'admin-pacientes-medicos' } },
|
||||
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' }
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' },
|
||||
{ label: 'Templates de Documentos', icon: 'pi pi-fw pi-file-edit', to: { name: 'admin-documents-templates' }, feature: 'documents.templates', proBadge: true }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -94,6 +96,8 @@ export default function adminMenu(ctx = {}) {
|
||||
{
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
|
||||
{ label: 'Meu Negócio', icon: 'pi pi-fw pi-building', to: '/account/negocio' },
|
||||
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: { name: 'admin-settings-security' } },
|
||||
{
|
||||
label: 'Agendamento Online (PRO)',
|
||||
|
||||
@@ -88,7 +88,8 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
||||
{ label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' },
|
||||
{ label: 'Carrossel Login', icon: 'pi pi-fw pi-images', to: '/saas/login-carousel' },
|
||||
{ label: 'Avisos Globais', icon: 'pi pi-fw pi-megaphone', to: '/saas/global-notices' },
|
||||
{ label: 'Templates de E-mail', icon: 'pi pi-fw pi-envelope', to: '/saas/email-templates' }
|
||||
{ label: 'Templates de E-mail', icon: 'pi pi-fw pi-envelope', to: '/saas/email-templates' },
|
||||
{ label: 'Templates de Documentos', icon: 'pi pi-fw pi-file-edit', to: '/saas/document-templates' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -36,6 +36,9 @@ export default [
|
||||
{ label: 'Meus pacientes', icon: 'pi pi-list', to: '/therapist/patients', quickCreate: true, quickCreateRoute: 'therapist-patients-cadastro', quickCreateLinkTo: '/therapist/patients/link-externo' },
|
||||
{ label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
|
||||
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
|
||||
{ label: 'Médicos & Referências', icon: 'pi pi-heart', to: '/therapist/patients/medicos' },
|
||||
{ label: 'Documentos', icon: 'pi pi-file', to: '/therapist/documents', feature: 'documents.upload' },
|
||||
{ label: 'Templates', icon: 'pi pi-file-edit', to: '/therapist/documents/templates', feature: 'documents.templates', proBadge: true },
|
||||
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' }
|
||||
]
|
||||
@@ -80,6 +83,7 @@ export default [
|
||||
items: [
|
||||
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/therapist/meu-plano' },
|
||||
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
|
||||
{ label: 'Meu Negócio', icon: 'pi pi-fw pi-building', to: '/account/negocio' },
|
||||
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
|
||||
]
|
||||
}
|
||||
|
||||
+20
-20
@@ -60,7 +60,7 @@ function readPendingInviteToken() {
|
||||
function clearPendingInviteToken() {
|
||||
try {
|
||||
sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY);
|
||||
} catch (_) {}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function isUuid(v) {
|
||||
@@ -382,7 +382,7 @@ export function applyGuards(router) {
|
||||
localStorage.removeItem('tenant_id');
|
||||
localStorage.removeItem('tenant');
|
||||
localStorage.removeItem('currentTenantId');
|
||||
} catch (_) {}
|
||||
} catch (_) { }
|
||||
|
||||
_perfEnd();
|
||||
return { path: '/portal' };
|
||||
@@ -438,11 +438,11 @@ export function applyGuards(router) {
|
||||
if (['therapist', 'supervisor'].includes(_roleNorm) && _ent.loadedForUser !== uid) {
|
||||
try {
|
||||
await _ent.loadForUser(uid);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
await ensureMenuBuilt({ uid, tenantId: _tid, tenantRole: _role, globalRole });
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
_perfEnd();
|
||||
return true;
|
||||
@@ -455,7 +455,7 @@ export function applyGuards(router) {
|
||||
localStorage.removeItem('tenant_id');
|
||||
localStorage.removeItem('tenant');
|
||||
localStorage.removeItem('currentTenantId');
|
||||
} catch (_) {}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -489,7 +489,7 @@ export function applyGuards(router) {
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// ================================
|
||||
@@ -548,7 +548,7 @@ export function applyGuards(router) {
|
||||
|
||||
// ================================
|
||||
// 🚫 SaaS master: bloqueia tenant-app por padrão
|
||||
// ✅ Mas libera rotas de DEMO em DEV (Sakai)
|
||||
// ✅ Mas libera rotas de DEMO em DEV
|
||||
// ================================
|
||||
logGuard('saas.lockdown?');
|
||||
|
||||
@@ -558,7 +558,7 @@ export function applyGuards(router) {
|
||||
if (isSaas) {
|
||||
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/');
|
||||
|
||||
// Rotas do Sakai Demo (no seu caso ficam em /demo/*)
|
||||
// Rotas do Tema Demo (no seu caso ficam em /demo/*)
|
||||
const isDemoArea = import.meta.env.DEV && (to.path === '/demo' || to.path.startsWith('/demo/'));
|
||||
|
||||
// Se for demo em DEV, libera
|
||||
@@ -693,19 +693,19 @@ export function applyGuards(router) {
|
||||
try {
|
||||
const entX = useEntitlementsStore();
|
||||
if (typeof entX.invalidate === 'function') entX.invalidate();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
// ✅ NÃO invalidar o cache inteiro — invalida somente o tenant anterior
|
||||
try {
|
||||
const tfX = useTenantFeaturesStore();
|
||||
if (typeof tfX.invalidate === 'function' && oldTenantId) tfX.invalidate(oldTenantId);
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
// ✅ troca tenant => menu precisa recompôr (contexto mudou)
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch {}
|
||||
} catch { }
|
||||
} else if (!desiredTenantId) {
|
||||
logGuard('[guards] tenantScope sem match', {
|
||||
scope,
|
||||
@@ -858,7 +858,7 @@ export function applyGuards(router) {
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
|
||||
try { resetAjuda(); } catch (_) {}
|
||||
try { resetAjuda(); } catch (_) { }
|
||||
|
||||
// ✅ FIX: limpa o localStorage de tenant na saída
|
||||
// Sem isso, o próximo login restaura o tenant do usuário anterior.
|
||||
@@ -866,27 +866,27 @@ export function applyGuards(router) {
|
||||
localStorage.removeItem('tenant_id');
|
||||
localStorage.removeItem('tenant');
|
||||
localStorage.removeItem('currentTenantId');
|
||||
} catch (_) {}
|
||||
} catch (_) { }
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore();
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore();
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const tenant = useTenantStore();
|
||||
if (typeof tenant.reset === 'function') tenant.reset();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -912,17 +912,17 @@ export function applyGuards(router) {
|
||||
try {
|
||||
const tf = useTenantFeaturesStore();
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore();
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
// tenantStore carrega de novo no fluxo do guard quando precisar
|
||||
return;
|
||||
|
||||
@@ -16,20 +16,22 @@
|
||||
*/
|
||||
import RouterPassthrough from '@/layout/RouterPassthrough.vue';
|
||||
|
||||
// Rotas compartilhadas — acessíveis por qualquer role autenticada
|
||||
export default {
|
||||
path: 'account',
|
||||
component: RouterPassthrough,
|
||||
meta: { requiresAuth: true, area: 'account' },
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: { name: 'account-profile' }
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'account-profile',
|
||||
component: () => import('@/views/pages/account/ProfilePage.vue')
|
||||
},
|
||||
{
|
||||
path: 'negocio',
|
||||
name: 'account-negocio',
|
||||
component: () => import('@/views/pages/account/NegocioPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
name: 'account-security',
|
||||
|
||||
@@ -126,6 +126,12 @@ export default {
|
||||
component: () => import('@/features/patients/tags/TagsPage.vue'),
|
||||
meta: { tenantFeature: 'patients' }
|
||||
},
|
||||
{
|
||||
path: 'pacientes/medicos',
|
||||
name: 'admin-pacientes-medicos',
|
||||
component: () => import('@/features/patients/medicos/MedicosPage.vue'),
|
||||
meta: { tenantFeature: 'patients' }
|
||||
},
|
||||
{
|
||||
path: 'pacientes/link-externo',
|
||||
name: 'admin-pacientes-link-externo',
|
||||
@@ -139,6 +145,16 @@ export default {
|
||||
meta: { tenantFeature: 'patients' }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📄 DOCUMENTOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'documents/templates',
|
||||
name: 'admin-documents-templates',
|
||||
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
|
||||
meta: { feature: 'documents.templates' }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔐 SEGURANÇA
|
||||
// ======================================================
|
||||
|
||||
@@ -51,6 +51,13 @@ export default {
|
||||
name: 'agendador.publico',
|
||||
component: () => import('@/views/pages/public/AgendadorPublicoPage.vue'),
|
||||
meta: { public: true }
|
||||
},
|
||||
// ✅ documento compartilhado via link temporário
|
||||
{
|
||||
path: '/shared/document/:token',
|
||||
name: 'shared.document',
|
||||
component: () => import('@/views/pages/public/SharedDocumentPage.vue'),
|
||||
meta: { public: true }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -138,6 +138,12 @@ export default {
|
||||
name: 'saas-addons',
|
||||
component: () => import('@/views/pages/saas/SaasAddonsPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'document-templates',
|
||||
name: 'saas-document-templates',
|
||||
component: () => import('@/views/pages/saas/SaasDocumentTemplatesPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -110,6 +110,11 @@ export default {
|
||||
name: 'therapist-patients-tags',
|
||||
component: () => import('@/features/patients/tags/TagsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'patients/medicos',
|
||||
name: 'therapist-patients-medicos',
|
||||
component: () => import('@/features/patients/medicos/MedicosPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'patients/link-externo',
|
||||
name: 'therapist-patients-link-externo',
|
||||
@@ -121,6 +126,29 @@ export default {
|
||||
component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📄 DOCUMENTOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'documents',
|
||||
name: 'therapist-documents',
|
||||
component: () => import('@/features/documents/DocumentsListPage.vue'),
|
||||
meta: { feature: 'documents.upload' }
|
||||
},
|
||||
{
|
||||
path: 'documents/templates',
|
||||
name: 'therapist-documents-templates',
|
||||
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
|
||||
meta: { feature: 'documents.templates' }
|
||||
},
|
||||
{
|
||||
path: 'patients/:id/documents',
|
||||
name: 'therapist-patient-documents',
|
||||
component: () => import('@/features/documents/DocumentsListPage.vue'),
|
||||
props: true,
|
||||
meta: { feature: 'documents.upload' }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Online Scheduling
|
||||
// ======================================================
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/services/DocumentAuditLog.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('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) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
// ── Registrar acesso ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registra acesso a um documento (visualizacao, download, etc.).
|
||||
* Tabela imutavel — somente INSERT.
|
||||
*
|
||||
* @param {string} documentoId
|
||||
* @param {string} acao - 'visualizou' | 'baixou' | 'imprimiu' | 'compartilhou' | 'assinou'
|
||||
*/
|
||||
export async function logAccess(documentoId, acao) {
|
||||
if (!documentoId || !acao) return;
|
||||
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('document_access_logs')
|
||||
.insert({
|
||||
documento_id: documentoId,
|
||||
tenant_id: tenantId,
|
||||
acao,
|
||||
user_id: ownerId
|
||||
});
|
||||
|
||||
// Nao lancar erro para nao interromper o fluxo principal
|
||||
if (error) console.error('[DocumentAuditLog] Erro ao registrar acesso:', error.message);
|
||||
}
|
||||
|
||||
// ── Listar historico de acessos ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Retorna historico de acessos de um documento.
|
||||
*/
|
||||
export async function listAccessLogs(documentoId) {
|
||||
if (!documentoId) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_access_logs')
|
||||
.select('*, profiles:user_id(full_name)')
|
||||
.eq('documento_id', documentoId)
|
||||
.order('acessado_em', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna historico de acessos de todos os documentos do tenant.
|
||||
* Util para auditoria geral.
|
||||
*
|
||||
* @param {object} filters - { dataInicio, dataFim, acao, userId }
|
||||
* @param {number} limit - maximo de registros (default 100)
|
||||
*/
|
||||
export async function listAllAccessLogs(filters = {}, limit = 100) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
let query = supabase
|
||||
.from('document_access_logs')
|
||||
.select('*, profiles:user_id(full_name), documents:documento_id(nome_original, patient_id)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('acessado_em', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (filters.acao) {
|
||||
query = query.eq('acao', filters.acao);
|
||||
}
|
||||
if (filters.userId) {
|
||||
query = query.eq('user_id', filters.userId);
|
||||
}
|
||||
if (filters.dataInicio) {
|
||||
query = query.gte('acessado_em', filters.dataInicio);
|
||||
}
|
||||
if (filters.dataFim) {
|
||||
query = query.lte('acessado_em', filters.dataFim);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conta acessos por tipo de acao para um documento.
|
||||
* Util para exibir badges (ex: "visualizado 5x, baixado 2x").
|
||||
*/
|
||||
export async function countAccessByAction(documentoId) {
|
||||
if (!documentoId) return {};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_access_logs')
|
||||
.select('acao')
|
||||
.eq('documento_id', documentoId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const counts = {};
|
||||
for (const row of data || []) {
|
||||
counts[row.acao] = (counts[row.acao] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/services/DocumentGenerate.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const BUCKET = 'generated-docs';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('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) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
// ── Carregar dados para preenchimento ───────────────────────
|
||||
|
||||
/**
|
||||
* Busca dados do paciente para preencher variaveis do template.
|
||||
*/
|
||||
export async function loadPatientData(patientId) {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.select(`
|
||||
nome_completo, nome_social, cpf, data_nascimento,
|
||||
telefone, email_principal,
|
||||
endereco, numero, bairro, cidade, estado, cep
|
||||
`)
|
||||
.eq('id', patientId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const p = data;
|
||||
const endereco = [p.endereco, p.numero, p.bairro, p.cidade, p.estado]
|
||||
.filter(Boolean).join(', ');
|
||||
|
||||
return {
|
||||
paciente_nome: p.nome_completo || '',
|
||||
paciente_nome_social: p.nome_social || '',
|
||||
paciente_cpf: p.cpf || '',
|
||||
paciente_data_nascimento: p.data_nascimento
|
||||
? new Date(p.data_nascimento).toLocaleDateString('pt-BR')
|
||||
: '',
|
||||
paciente_telefone: p.telefone || '',
|
||||
paciente_email: p.email_principal || '',
|
||||
paciente_endereco: endereco
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca dados da sessao (agenda_evento) para preencher variaveis.
|
||||
*/
|
||||
export async function loadSessionData(agendaEventoId) {
|
||||
if (!agendaEventoId) return {};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('inicio_em, fim_em, modalidade, price')
|
||||
.eq('id', agendaEventoId)
|
||||
.single();
|
||||
|
||||
if (error) return {};
|
||||
|
||||
const s = data;
|
||||
const inicio = s.inicio_em ? new Date(s.inicio_em) : null;
|
||||
const fim = s.fim_em ? new Date(s.fim_em) : null;
|
||||
|
||||
return {
|
||||
data_sessao: inicio ? inicio.toLocaleDateString('pt-BR') : '',
|
||||
hora_inicio: inicio ? inicio.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
|
||||
hora_fim: fim ? fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
|
||||
modalidade: s.modalidade || '',
|
||||
valor: s.price ? `R$ ${Number(s.price).toFixed(2).replace('.', ',')}` : ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca dados do terapeuta (profile + tenant_member).
|
||||
*/
|
||||
export async function loadTherapistData() {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, phone')
|
||||
.eq('id', ownerId)
|
||||
.single();
|
||||
|
||||
// Email vem de auth.users (nao existe em profiles)
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const email = userData?.user?.email || '';
|
||||
|
||||
return {
|
||||
terapeuta_nome: profile?.full_name || '',
|
||||
terapeuta_crp: '', // CRP ainda nao existe no banco — preencher manualmente
|
||||
terapeuta_email: email,
|
||||
terapeuta_telefone: profile?.phone || ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca dados da clinica (tenant).
|
||||
*/
|
||||
export async function loadClinicData(tenantId) {
|
||||
// Usa select('*') pois campos de endereço (logradouro, numero, etc.)
|
||||
// dependem da migration 003_tenants_address_fields ter sido aplicada
|
||||
const { data: tenant } = await supabase
|
||||
.from('tenants')
|
||||
.select('*')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!tenant) {
|
||||
return { clinica_nome: '', clinica_endereco: '', clinica_telefone: '', clinica_cnpj: '' };
|
||||
}
|
||||
|
||||
// Usa campos estruturados se disponiveis, senao cai no address texto livre
|
||||
const endereco = tenant.logradouro
|
||||
? [tenant.logradouro, tenant.numero, tenant.bairro, tenant.cidade, tenant.estado]
|
||||
.filter(Boolean).join(', ')
|
||||
: tenant.address || '';
|
||||
|
||||
return {
|
||||
clinica_nome: tenant.name || '',
|
||||
clinica_endereco: endereco,
|
||||
clinica_telefone: tenant.phone || '',
|
||||
clinica_cnpj: ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── Montar dados gerais ─────────────────────────────────────
|
||||
|
||||
function getDateVariables() {
|
||||
const now = new Date();
|
||||
const meses = [
|
||||
'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho',
|
||||
'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'
|
||||
];
|
||||
return {
|
||||
data_atual: now.toLocaleDateString('pt-BR'),
|
||||
data_atual_extenso: `${now.getDate()} de ${meses[now.getMonth()]} de ${now.getFullYear()}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega todos os dados necessarios para preencher um template.
|
||||
*/
|
||||
export async function loadAllVariables(patientId, agendaEventoId = null) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const [patient, session, therapist, clinic] = await Promise.all([
|
||||
loadPatientData(patientId),
|
||||
loadSessionData(agendaEventoId),
|
||||
loadTherapistData(),
|
||||
loadClinicData(tenantId)
|
||||
]);
|
||||
|
||||
return {
|
||||
...patient,
|
||||
...session,
|
||||
...therapist,
|
||||
...clinic,
|
||||
...getDateVariables(),
|
||||
cidade_estado: clinic.clinica_endereco
|
||||
? `${clinic.clinica_endereco.split(', ').slice(-2).join('/')}`
|
||||
: ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── Preencher template ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Substitui {{variavel}} no HTML pelos valores fornecidos.
|
||||
*/
|
||||
export function fillTemplate(html, variables = {}) {
|
||||
return String(html || '').replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return variables[key] !== undefined ? String(variables[key]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o HTML completo do documento (cabecalho + corpo + rodape).
|
||||
*/
|
||||
export function buildFullHtml(template, variables = {}) {
|
||||
const cabecalho = fillTemplate(template.cabecalho_html || '', variables);
|
||||
const corpo = fillTemplate(template.corpo_html || '', variables);
|
||||
const rodape = fillTemplate(template.rodape_html || '', variables);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" style="color-scheme:light;">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
*, *::before, *::after { color-scheme: light; }
|
||||
@page { size: A4; margin: 20mm 15mm 25mm 15mm; }
|
||||
html, body {
|
||||
all: initial;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: #ffffff;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
h1, h2, h3, h4, p, ul, ol, li, table, tr, td, th, div, span, strong, em, hr, a {
|
||||
all: revert;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
h2 { font-size: 16pt; margin-bottom: 16px; }
|
||||
h3 { font-size: 13pt; margin-top: 20px; margin-bottom: 8px; }
|
||||
p { margin: 8px 0; }
|
||||
table { border-collapse: collapse; }
|
||||
td { padding: 4px 8px; }
|
||||
hr { border: none; border-top: 1px solid #333333; }
|
||||
a { color: #2563eb; }
|
||||
.doc-header { text-align: center; margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px solid #cccccc; }
|
||||
.doc-header img { max-height: 60px; margin-bottom: 8px; }
|
||||
.doc-content { min-height: 600px; }
|
||||
.doc-footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #cccccc; font-size: 10pt; color: #666666; text-align: center; }
|
||||
.signature-line { margin-top: 60px; text-align: center; }
|
||||
.signature-line hr { width: 250px; margin: 0 auto 4px; border: none; border-top: 1px solid #333333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc-header">${cabecalho}</div>
|
||||
<div class="doc-content">${corpo}</div>
|
||||
<div class="doc-footer">${rodape}</div>
|
||||
</body>
|
||||
</html>`.trim();
|
||||
}
|
||||
|
||||
// ── Gerar PDF (jsPDF + html2canvas via pdf.service) ────────
|
||||
|
||||
import { htmlToPdfBlob, htmlToPdfDownload, htmlToPdfOpen } from '@/services/pdf.service';
|
||||
|
||||
/**
|
||||
* Gera um Blob PDF a partir do template preenchido.
|
||||
*/
|
||||
export async function generatePdfBlob(template, variables = {}) {
|
||||
const html = buildFullHtml(template, variables);
|
||||
return await htmlToPdfBlob(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera PDF e dispara download automatico.
|
||||
*/
|
||||
export async function generateAndDownloadPdf(template, variables = {}, filename = 'documento.pdf') {
|
||||
const html = buildFullHtml(template, variables);
|
||||
await htmlToPdfDownload(html, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre o PDF em nova aba para impressao.
|
||||
*/
|
||||
export async function printDocument(template, variables = {}) {
|
||||
const html = buildFullHtml(template, variables);
|
||||
await htmlToPdfOpen(html);
|
||||
}
|
||||
|
||||
// ── Salvar documento gerado ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registra um documento gerado na tabela document_generated.
|
||||
* O PDF deve ser passado como Blob (gerado client-side ou server-side).
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.templateId
|
||||
* @param {string} params.patientId
|
||||
* @param {object} params.dadosPreenchidos - snapshot dos dados usados
|
||||
* @param {Blob} params.pdfBlob - PDF gerado (opcional — pode ser null se so print)
|
||||
* @returns {object} registro criado
|
||||
*/
|
||||
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome }) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
let pdfPath = '';
|
||||
const timestamp = Date.now();
|
||||
const safeNome = (templateNome || 'documento')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // remove acentos
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const filename = `${safeNome}_${timestamp}.pdf`;
|
||||
|
||||
// Se tiver um blob PDF, faz upload ao Storage
|
||||
if (pdfBlob) {
|
||||
pdfPath = `${tenantId}/${patientId}/${filename}`;
|
||||
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from(BUCKET)
|
||||
.upload(pdfPath, pdfBlob, { contentType: 'application/pdf' });
|
||||
|
||||
if (upErr) throw upErr;
|
||||
}
|
||||
|
||||
// Registra na tabela document_generated
|
||||
const { data, error } = await supabase
|
||||
.from('document_generated')
|
||||
.insert({
|
||||
template_id: templateId,
|
||||
patient_id: patientId,
|
||||
tenant_id: tenantId,
|
||||
dados_preenchidos: dadosPreenchidos || {},
|
||||
pdf_path: pdfPath,
|
||||
storage_bucket: BUCKET,
|
||||
gerado_por: ownerId
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Registra na tabela documents para aparecer na lista do paciente
|
||||
// Reutiliza o mesmo arquivo do bucket generated-docs (sem upload duplicado)
|
||||
if (pdfPath) {
|
||||
await supabase
|
||||
.from('documents')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
patient_id: patientId,
|
||||
bucket_path: pdfPath,
|
||||
storage_bucket: BUCKET,
|
||||
nome_original: filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
tamanho_bytes: pdfBlob?.size || null,
|
||||
tipo_documento: 'laudo',
|
||||
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`,
|
||||
tags: ['gerado'],
|
||||
visibilidade: 'privado',
|
||||
status_revisao: 'aprovado',
|
||||
uploaded_by: ownerId
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista documentos gerados de um paciente.
|
||||
*/
|
||||
export async function listGeneratedDocuments(patientId) {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_generated')
|
||||
.select('*, document_templates(nome_template, tipo)')
|
||||
.eq('gerado_por', ownerId)
|
||||
.eq('patient_id', patientId)
|
||||
.order('gerado_em', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/services/DocumentShareLinks.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('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) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
// ── Criar link temporario ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera link temporario para compartilhar documento com profissional externo.
|
||||
*
|
||||
* @param {string} documentoId
|
||||
* @param {object} opts - { expiracaoHoras: 48, usosMax: 5 }
|
||||
* @returns {object} registro com token para montar a URL
|
||||
*/
|
||||
export async function createShareLink(documentoId, opts = {}) {
|
||||
if (!documentoId) throw new Error('Documento não informado.');
|
||||
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const expiracaoHoras = opts.expiracaoHoras || 48;
|
||||
const expiraEm = new Date();
|
||||
expiraEm.setHours(expiraEm.getHours() + expiracaoHoras);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_share_links')
|
||||
.insert({
|
||||
documento_id: documentoId,
|
||||
tenant_id: tenantId,
|
||||
expira_em: expiraEm.toISOString(),
|
||||
usos_max: opts.usosMax || 5,
|
||||
criado_por: ownerId
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Listar links de um documento ────────────────────────────
|
||||
|
||||
export async function listShareLinks(documentoId) {
|
||||
if (!documentoId) return [];
|
||||
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_share_links')
|
||||
.select('*')
|
||||
.eq('documento_id', documentoId)
|
||||
.eq('criado_por', ownerId)
|
||||
.order('criado_em', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Validar token (acesso publico) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Valida token de compartilhamento e retorna dados do documento.
|
||||
* Incrementa o contador de usos.
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {object|null} - { link, document } ou null se invalido/expirado
|
||||
*/
|
||||
export async function validateShareToken(token) {
|
||||
if (!token) return null;
|
||||
|
||||
// Buscar link ativo
|
||||
const { data: link, error } = await supabase
|
||||
.from('document_share_links')
|
||||
.select('*')
|
||||
.eq('token', token)
|
||||
.eq('ativo', true)
|
||||
.single();
|
||||
|
||||
if (error || !link) return null;
|
||||
|
||||
// Verificar expiracao
|
||||
if (new Date(link.expira_em) < new Date()) return null;
|
||||
|
||||
// Verificar limite de usos
|
||||
if (link.usos >= link.usos_max) return null;
|
||||
|
||||
// Incrementar uso
|
||||
await supabase
|
||||
.from('document_share_links')
|
||||
.update({ usos: link.usos + 1 })
|
||||
.eq('id', link.id);
|
||||
|
||||
// Buscar documento
|
||||
const { data: doc } = await supabase
|
||||
.from('documents')
|
||||
.select('id, nome_original, mime_type, bucket_path, storage_bucket')
|
||||
.eq('id', link.documento_id)
|
||||
.single();
|
||||
|
||||
return { link, document: doc };
|
||||
}
|
||||
|
||||
// ── Desativar link ──────────────────────────────────────────
|
||||
|
||||
export async function deactivateShareLink(linkId) {
|
||||
if (!linkId) throw new Error('ID inválido.');
|
||||
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('document_share_links')
|
||||
.update({ ativo: false })
|
||||
.eq('id', linkId)
|
||||
.eq('criado_por', ownerId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Montar URL publica ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Monta a URL de compartilhamento a partir do token.
|
||||
* A rota publica deve ser configurada no router.
|
||||
*/
|
||||
export function buildShareUrl(token) {
|
||||
const base = window.location.origin;
|
||||
return `${base}/shared/document/${token}`;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/services/DocumentSignatures.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('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) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
// ── Hash do documento ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera hash SHA-256 de um ArrayBuffer (conteudo do arquivo).
|
||||
*/
|
||||
export async function hashDocument(arrayBuffer) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// ── Criar solicitacao de assinatura ─────────────────────────
|
||||
|
||||
/**
|
||||
* Cria uma ou mais solicitacoes de assinatura para um documento.
|
||||
*
|
||||
* @param {string} documentoId - UUID do documento
|
||||
* @param {Array} signatarios - [{ tipo, nome, email, id? }]
|
||||
* tipo: 'paciente' | 'responsavel_legal' | 'terapeuta'
|
||||
*/
|
||||
export async function createSignatureRequests(documentoId, signatarios = []) {
|
||||
if (!documentoId) throw new Error('Documento não informado.');
|
||||
if (!signatarios.length) throw new Error('Ao menos um signatário é necessário.');
|
||||
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const rows = signatarios.map((s, idx) => ({
|
||||
documento_id: documentoId,
|
||||
tenant_id: tenantId,
|
||||
signatario_tipo: s.tipo || 'paciente',
|
||||
signatario_id: s.id || null,
|
||||
signatario_nome: s.nome || null,
|
||||
signatario_email: s.email || null,
|
||||
ordem: idx + 1,
|
||||
status: 'pendente'
|
||||
}));
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_signatures')
|
||||
.insert(rows)
|
||||
.select('*');
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Registrar assinatura ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registra que um signatario assinou o documento.
|
||||
*
|
||||
* @param {string} signatureId - UUID da solicitacao de assinatura
|
||||
* @param {object} meta - { ip, user_agent, hash_documento }
|
||||
*/
|
||||
export async function registerSignature(signatureId, meta = {}) {
|
||||
if (!signatureId) throw new Error('ID da assinatura inválido.');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_signatures')
|
||||
.update({
|
||||
status: 'assinado',
|
||||
ip: meta.ip || null,
|
||||
user_agent: meta.user_agent || null,
|
||||
assinado_em: new Date().toISOString(),
|
||||
hash_documento: meta.hash_documento || null
|
||||
})
|
||||
.eq('id', signatureId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Listar assinaturas de um documento ──────────────────────
|
||||
|
||||
export async function listSignatures(documentoId) {
|
||||
if (!documentoId) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_signatures')
|
||||
.select('*')
|
||||
.eq('documento_id', documentoId)
|
||||
.order('ordem', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Status geral do documento ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Retorna o status consolidado de assinaturas de um documento.
|
||||
*
|
||||
* @returns {{ total, assinados, pendentes, status }}
|
||||
* status: 'completo' | 'parcial' | 'pendente' | 'sem_assinaturas'
|
||||
*/
|
||||
export async function getSignatureStatus(documentoId) {
|
||||
const sigs = await listSignatures(documentoId);
|
||||
if (!sigs.length) return { total: 0, assinados: 0, pendentes: 0, status: 'sem_assinaturas' };
|
||||
|
||||
const assinados = sigs.filter(s => s.status === 'assinado').length;
|
||||
const pendentes = sigs.length - assinados;
|
||||
|
||||
let status = 'pendente';
|
||||
if (assinados === sigs.length) status = 'completo';
|
||||
else if (assinados > 0) status = 'parcial';
|
||||
|
||||
return { total: sigs.length, assinados, pendentes, status };
|
||||
}
|
||||
|
||||
// ── Recusar assinatura ──────────────────────────────────────
|
||||
|
||||
export async function refuseSignature(signatureId) {
|
||||
if (!signatureId) throw new Error('ID da assinatura inválido.');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_signatures')
|
||||
.update({
|
||||
status: 'recusado',
|
||||
atualizado_em: new Date().toISOString()
|
||||
})
|
||||
.eq('id', signatureId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/services/DocumentTemplates.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('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) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
// ── Variaveis disponíveis ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Variaveis que podem ser usadas nos templates.
|
||||
* Cada variavel tem: key, label (pt-BR), grupo.
|
||||
*/
|
||||
export const TEMPLATE_VARIABLES = [
|
||||
// Paciente
|
||||
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente' },
|
||||
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente' },
|
||||
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente' },
|
||||
|
||||
// Sessao
|
||||
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão' },
|
||||
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão' },
|
||||
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão' },
|
||||
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão' },
|
||||
|
||||
// Terapeuta
|
||||
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_crp', label: 'CRP do terapeuta', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
|
||||
|
||||
// Clinica
|
||||
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica' },
|
||||
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica' },
|
||||
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica' },
|
||||
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica' },
|
||||
|
||||
// Financeiro
|
||||
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro' },
|
||||
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro' },
|
||||
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro' },
|
||||
|
||||
// Datas
|
||||
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas' },
|
||||
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas' },
|
||||
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas' }
|
||||
];
|
||||
|
||||
// ── List ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lista templates disponíveis: globais + do tenant do usuario.
|
||||
*/
|
||||
export async function listTemplates() {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_templates')
|
||||
.select('*')
|
||||
.or(`is_global.eq.true,tenant_id.eq.${tenantId}`)
|
||||
.eq('ativo', true)
|
||||
.order('nome_template', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os templates (incluindo inativos) — para pagina de gestao.
|
||||
*/
|
||||
export async function listAllTemplates() {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_templates')
|
||||
.select('*')
|
||||
.or(`is_global.eq.true,tenant_id.eq.${tenantId}`)
|
||||
.order('is_global', { ascending: false })
|
||||
.order('nome_template', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Get one ─────────────────────────────────────────────────
|
||||
|
||||
export async function getTemplate(id) {
|
||||
const { data, error } = await supabase
|
||||
.from('document_templates')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────
|
||||
|
||||
export async function createTemplate(payload) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const nome = String(payload.nome_template || '').trim();
|
||||
if (!nome) throw new Error('Nome do template é obrigatório.');
|
||||
|
||||
const row = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome_template: nome,
|
||||
tipo: payload.tipo || 'outro',
|
||||
descricao: payload.descricao || null,
|
||||
corpo_html: payload.corpo_html || '',
|
||||
cabecalho_html: payload.cabecalho_html || null,
|
||||
rodape_html: payload.rodape_html || null,
|
||||
variaveis: payload.variaveis || [],
|
||||
logo_url: payload.logo_url || null,
|
||||
is_global: false,
|
||||
ativo: true
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_templates')
|
||||
.insert(row)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────
|
||||
|
||||
export async function updateTemplate(id, payload) {
|
||||
const ownerId = await getOwnerId();
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const row = {};
|
||||
if (payload.nome_template !== undefined) row.nome_template = String(payload.nome_template).trim();
|
||||
if (payload.tipo !== undefined) row.tipo = payload.tipo;
|
||||
if (payload.descricao !== undefined) row.descricao = payload.descricao;
|
||||
if (payload.corpo_html !== undefined) row.corpo_html = payload.corpo_html;
|
||||
if (payload.cabecalho_html !== undefined) row.cabecalho_html = payload.cabecalho_html;
|
||||
if (payload.rodape_html !== undefined) row.rodape_html = payload.rodape_html;
|
||||
if (payload.variaveis !== undefined) row.variaveis = payload.variaveis;
|
||||
if (payload.logo_url !== undefined) row.logo_url = payload.logo_url;
|
||||
if (payload.ativo !== undefined) row.ativo = payload.ativo;
|
||||
|
||||
row.updated_at = new Date().toISOString();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_templates')
|
||||
.update(row)
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Delete (soft) ───────────────────────────────────────────
|
||||
|
||||
export async function deleteTemplate(id) {
|
||||
const ownerId = await getOwnerId();
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('document_templates')
|
||||
.update({ ativo: false, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Duplicate ───────────────────────────────────────────────
|
||||
|
||||
export async function duplicateTemplate(id) {
|
||||
const original = await getTemplate(id);
|
||||
if (!original) throw new Error('Template não encontrado.');
|
||||
|
||||
return createTemplate({
|
||||
nome_template: original.nome_template + ' (cópia)',
|
||||
tipo: original.tipo,
|
||||
descricao: original.descricao,
|
||||
corpo_html: original.corpo_html,
|
||||
cabecalho_html: original.cabecalho_html,
|
||||
rodape_html: original.rodape_html,
|
||||
variaveis: original.variaveis,
|
||||
logo_url: original.logo_url
|
||||
});
|
||||
}
|
||||
|
||||
// ── Extrair variaveis do HTML ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Extrai variaveis {{nome}} do corpo HTML de um template.
|
||||
*/
|
||||
export function extractVariablesFromHtml(html) {
|
||||
const matches = String(html || '').match(/\{\{(\w+)\}\}/g) || [];
|
||||
const keys = matches.map(m => m.replace(/\{\{|\}\}/g, ''));
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/services/Documents.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const BUCKET = 'documents';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('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) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
function buildStoragePath(tenantId, patientId, fileName) {
|
||||
const timestamp = Date.now();
|
||||
const safe = String(fileName || 'arquivo').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return `${tenantId}/${patientId}/${timestamp}-${safe}`;
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Faz upload de arquivo ao Storage e registra na tabela documents.
|
||||
*
|
||||
* @param {File} file - Objeto File do input
|
||||
* @param {string} patientId - UUID do paciente
|
||||
* @param {object} meta - { tipo_documento, categoria, descricao, tags[], agenda_evento_id, visibilidade }
|
||||
* @returns {object} - Registro criado em documents
|
||||
*/
|
||||
export async function uploadDocument(file, patientId, meta = {}) {
|
||||
if (!file) throw new Error('Nenhum arquivo selecionado.');
|
||||
if (!patientId) throw new Error('Paciente não informado.');
|
||||
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
// Upload ao Storage
|
||||
const path = buildStoragePath(tenantId, patientId, file.name);
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from(BUCKET)
|
||||
.upload(path, file, { contentType: file.type });
|
||||
|
||||
if (upErr) throw upErr;
|
||||
|
||||
// Insert na tabela
|
||||
const row = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
patient_id: patientId,
|
||||
bucket_path: path,
|
||||
storage_bucket: BUCKET,
|
||||
nome_original: file.name,
|
||||
mime_type: file.type || null,
|
||||
tamanho_bytes: file.size || null,
|
||||
tipo_documento: meta.tipo_documento || 'outro',
|
||||
categoria: meta.categoria || null,
|
||||
descricao: meta.descricao || null,
|
||||
tags: meta.tags || [],
|
||||
agenda_evento_id: meta.agenda_evento_id || null,
|
||||
visibilidade: meta.visibilidade || 'privado',
|
||||
compartilhado_portal: meta.compartilhado_portal || false,
|
||||
compartilhado_supervisor: meta.compartilhado_supervisor || false,
|
||||
enviado_pelo_paciente: meta.enviado_pelo_paciente || false,
|
||||
status_revisao: meta.enviado_pelo_paciente ? 'pendente' : 'aprovado',
|
||||
uploaded_by: ownerId
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.insert(row)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
// Tenta limpar o arquivo do Storage em caso de erro no insert
|
||||
await supabase.storage.from(BUCKET).remove([path]).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── List ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lista documentos de um paciente (excluindo soft-deleted).
|
||||
*
|
||||
* @param {string} patientId
|
||||
* @param {object} filters - { tipo_documento, categoria, tag, search }
|
||||
*/
|
||||
export async function listDocuments(patientId, filters = {}) {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
let query = supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('patient_id', patientId)
|
||||
.is('deleted_at', null)
|
||||
.order('uploaded_at', { ascending: false });
|
||||
|
||||
if (filters.tipo_documento) {
|
||||
query = query.eq('tipo_documento', filters.tipo_documento);
|
||||
}
|
||||
if (filters.categoria) {
|
||||
query = query.eq('categoria', filters.categoria);
|
||||
}
|
||||
if (filters.tag) {
|
||||
query = query.contains('tags', [filters.tag]);
|
||||
}
|
||||
if (filters.search) {
|
||||
query = query.ilike('nome_original', `%${filters.search}%`);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os documentos do owner (todos os pacientes).
|
||||
*/
|
||||
export async function listAllDocuments(filters = {}) {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
let query = supabase
|
||||
.from('documents')
|
||||
.select('*, patients!inner(nome_completo)')
|
||||
.eq('owner_id', ownerId)
|
||||
.is('deleted_at', null)
|
||||
.order('uploaded_at', { ascending: false });
|
||||
|
||||
if (filters.tipo_documento) {
|
||||
query = query.eq('tipo_documento', filters.tipo_documento);
|
||||
}
|
||||
if (filters.search) {
|
||||
query = query.ilike('nome_original', `%${filters.search}%`);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Get one ─────────────────────────────────────────────────
|
||||
|
||||
export async function getDocument(id) {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────
|
||||
|
||||
export async function updateDocument(id, payload) {
|
||||
const ownerId = await getOwnerId();
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const row = {};
|
||||
if (payload.tipo_documento !== undefined) row.tipo_documento = payload.tipo_documento;
|
||||
if (payload.categoria !== undefined) row.categoria = payload.categoria;
|
||||
if (payload.descricao !== undefined) row.descricao = payload.descricao;
|
||||
if (payload.tags !== undefined) row.tags = payload.tags;
|
||||
if (payload.visibilidade !== undefined) row.visibilidade = payload.visibilidade;
|
||||
if (payload.compartilhado_portal !== undefined) row.compartilhado_portal = payload.compartilhado_portal;
|
||||
if (payload.compartilhado_supervisor !== undefined) row.compartilhado_supervisor = payload.compartilhado_supervisor;
|
||||
if (payload.status_revisao !== undefined) {
|
||||
row.status_revisao = payload.status_revisao;
|
||||
row.revisado_por = ownerId;
|
||||
row.revisado_em = new Date().toISOString();
|
||||
}
|
||||
|
||||
row.updated_at = new Date().toISOString();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.update(row)
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Soft Delete ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft delete com retencao. O arquivo permanece no Storage.
|
||||
* retencaoAnos: numero de anos de retencao (padrao 5 — CFP).
|
||||
*/
|
||||
export async function softDeleteDocument(id, retencaoAnos = 5) {
|
||||
const ownerId = await getOwnerId();
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const retencaoAte = new Date();
|
||||
retencaoAte.setFullYear(retencaoAte.getFullYear() + retencaoAnos);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('documents')
|
||||
.update({
|
||||
deleted_at: new Date().toISOString(),
|
||||
deleted_by: ownerId,
|
||||
retencao_ate: retencaoAte.toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurar documento soft-deleted.
|
||||
*/
|
||||
export async function restoreDocument(id) {
|
||||
const ownerId = await getOwnerId();
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('documents')
|
||||
.update({
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
retencao_ate: null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Download URL ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera URL assinada para download (valida por 60s por padrao).
|
||||
*/
|
||||
export async function getDownloadUrl(bucketPath, expiresIn = 60, bucket = BUCKET) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.createSignedUrl(bucketPath, expiresIn);
|
||||
|
||||
if (error) throw error;
|
||||
return data?.signedUrl;
|
||||
}
|
||||
|
||||
// ── Tags (autocomplete) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retorna tags unicas ja usadas pelo owner (para autocomplete).
|
||||
*/
|
||||
export async function getUsedTags() {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.select('tags')
|
||||
.eq('owner_id', ownerId)
|
||||
.is('deleted_at', null);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const set = new Set();
|
||||
for (const row of data || []) {
|
||||
for (const tag of row.tags || []) {
|
||||
if (tag) set.add(tag);
|
||||
}
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'pt-BR'));
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/services/Medicos.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('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) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
function normalizeNome(s) {
|
||||
return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function isUniqueViolation(err) {
|
||||
if (!err) return false;
|
||||
if (err.code === '23505') return true;
|
||||
return /duplicate key value violates unique constraint/i.test(String(err.message || ''));
|
||||
}
|
||||
|
||||
// ── List ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lista médicos ativos do owner com contagem de pacientes encaminhados.
|
||||
* A contagem é feita buscando quantos patients possuem o nome do médico
|
||||
* no campo `encaminhado_por` (text).
|
||||
*/
|
||||
export async function listMedicosWithPatientCounts() {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
const { data: medicos, error } = await supabase
|
||||
.from('medicos')
|
||||
.select('id, nome, crm, especialidade, telefone_profissional, telefone_pessoal, email, clinica, cidade, estado, observacoes, ativo, owner_id, tenant_id, created_at, updated_at')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('ativo', true)
|
||||
.order('nome', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Busca pacientes do owner para contar encaminhamentos por médico
|
||||
const { data: patients, error: pErr } = await supabase
|
||||
.from('patients')
|
||||
.select('id, encaminhado_por')
|
||||
.eq('owner_id', ownerId);
|
||||
|
||||
if (pErr) throw pErr;
|
||||
|
||||
const countMap = new Map();
|
||||
for (const med of medicos || []) {
|
||||
countMap.set(med.id, 0);
|
||||
}
|
||||
|
||||
for (const p of patients || []) {
|
||||
const enc = String(p.encaminhado_por || '').toLowerCase();
|
||||
if (!enc) continue;
|
||||
for (const med of medicos || []) {
|
||||
const nomeLower = med.nome.toLowerCase();
|
||||
if (enc.includes(nomeLower)) {
|
||||
countMap.set(med.id, (countMap.get(med.id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (medicos || []).map((m) => ({
|
||||
...m,
|
||||
patients_count: countMap.get(m.id) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────
|
||||
|
||||
export async function createMedico(payload) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const nome = String(payload.nome || '').trim();
|
||||
if (!nome) throw new Error('Nome do médico é obrigatório.');
|
||||
|
||||
const row = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome,
|
||||
crm: String(payload.crm || '').trim() || null,
|
||||
especialidade: payload.especialidade || null,
|
||||
telefone_profissional: payload.telefone_profissional || null,
|
||||
telefone_pessoal: payload.telefone_pessoal || null,
|
||||
email: String(payload.email || '').trim() || null,
|
||||
clinica: String(payload.clinica || '').trim() || null,
|
||||
cidade: String(payload.cidade || '').trim() || null,
|
||||
estado: String(payload.estado || '').trim() || null,
|
||||
observacoes: String(payload.observacoes || '').trim() || null,
|
||||
ativo: true
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('medicos')
|
||||
.insert(row)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (isUniqueViolation(error)) throw new Error('Já existe um médico com este CRM.');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────
|
||||
|
||||
export async function updateMedico(id, payload) {
|
||||
const ownerId = await getOwnerId();
|
||||
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const nome = String(payload.nome || '').trim();
|
||||
if (!nome) throw new Error('Nome do médico é obrigatório.');
|
||||
|
||||
const row = {
|
||||
nome,
|
||||
crm: String(payload.crm || '').trim() || null,
|
||||
especialidade: payload.especialidade || null,
|
||||
telefone_profissional: payload.telefone_profissional || null,
|
||||
telefone_pessoal: payload.telefone_pessoal || null,
|
||||
email: String(payload.email || '').trim() || null,
|
||||
clinica: String(payload.clinica || '').trim() || null,
|
||||
cidade: String(payload.cidade || '').trim() || null,
|
||||
estado: String(payload.estado || '').trim() || null,
|
||||
observacoes: String(payload.observacoes || '').trim() || null,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('medicos')
|
||||
.update(row)
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (isUniqueViolation(error)) throw new Error('Já existe um médico com este CRM.');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Delete (soft) ────────────────────────────────────────────
|
||||
|
||||
export async function deleteMedico(id) {
|
||||
const ownerId = await getOwnerId();
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('medicos')
|
||||
.update({ ativo: false, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('owner_id', ownerId);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Pacientes de um médico ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Busca pacientes do owner cujo campo `encaminhado_por` contém o nome do médico.
|
||||
*/
|
||||
export async function fetchPatientsByMedicoNome(medicoNome) {
|
||||
const ownerId = await getOwnerId();
|
||||
const nomeLower = String(medicoNome || '').trim().toLowerCase();
|
||||
if (!nomeLower) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, email_principal, telefone, avatar_url, encaminhado_por')
|
||||
.eq('owner_id', ownerId)
|
||||
.ilike('encaminhado_por', `%${nomeLower}%`);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return (data || [])
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
full_name: p.nome_completo || '—',
|
||||
email: p.email_principal || '—',
|
||||
phone: p.telefone || '—',
|
||||
avatar_url: p.avatar_url || null,
|
||||
encaminhado_por: p.encaminhado_por || ''
|
||||
}))
|
||||
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'));
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| PDF SERVICE — jsPDF + html2canvas
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Gera PDF a partir de HTML renderizado no browser.
|
||||
| Retorna Blob para download local e upload ao Storage.
|
||||
|
|
||||
*/
|
||||
|
||||
import { jsPDF } from 'jspdf';
|
||||
import html2canvas from 'html2canvas-pro';
|
||||
|
||||
const A4 = { width: 595.28, height: 841.89 }; // pontos (72dpi)
|
||||
const MARGIN = 40; // pontos
|
||||
|
||||
/**
|
||||
* Renderiza HTML completo em um Blob PDF.
|
||||
*
|
||||
* @param {string} html - HTML completo do documento (com <html>, <style>, etc.)
|
||||
* @returns {Promise<Blob>} PDF blob
|
||||
*/
|
||||
export async function htmlToPdfBlob(html) {
|
||||
// Cria container temporario oculto para renderizar o HTML
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
position: fixed; left: -9999px; top: 0;
|
||||
width: 794px;
|
||||
background: white;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
color: #1a1a1a;
|
||||
`;
|
||||
// 794px ≈ A4 width a 96dpi
|
||||
|
||||
// Injeta o HTML (extrai o body content se vier documento completo)
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
const styleMatch = html.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
|
||||
|
||||
if (styleMatch) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = styleMatch[1];
|
||||
container.appendChild(style);
|
||||
}
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.innerHTML = bodyMatch ? bodyMatch[1] : html;
|
||||
container.appendChild(content);
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 1.5, // boa qualidade sem exagerar no tamanho
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
width: 794,
|
||||
windowWidth: 794
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL('image/jpeg', 0.85);
|
||||
const pdf = new jsPDF('p', 'pt', 'a4');
|
||||
|
||||
const imgWidth = A4.width - (MARGIN * 2);
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
|
||||
const pageHeight = A4.height - (MARGIN * 2);
|
||||
let position = MARGIN;
|
||||
let heightLeft = imgHeight;
|
||||
|
||||
pdf.addImage(imgData, 'JPEG', MARGIN, position, imgWidth, imgHeight, undefined, 'FAST');
|
||||
heightLeft -= pageHeight;
|
||||
|
||||
while (heightLeft > 0) {
|
||||
position = -(imgHeight - heightLeft) + MARGIN;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, 'JPEG', MARGIN, position, imgWidth, imgHeight, undefined, 'FAST');
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
return pdf.output('blob');
|
||||
} finally {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera PDF e dispara download no browser.
|
||||
*
|
||||
* @param {string} html - HTML completo
|
||||
* @param {string} filename - nome do arquivo
|
||||
*/
|
||||
export async function htmlToPdfDownload(html, filename = 'documento.pdf') {
|
||||
const blob = await htmlToPdfBlob(html);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera PDF e abre em nova aba para impressao.
|
||||
*
|
||||
* @param {string} html - HTML completo
|
||||
*/
|
||||
export async function htmlToPdfOpen(html) {
|
||||
const blob = await htmlToPdfBlob(html);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Validadores e formatadores centralizados — AgenciaPsi
|
||||
*
|
||||
* Nomenclatura alinhada ao schema do banco:
|
||||
* nome_completo, cpf, cpf_responsavel, telefone, telefone_alternativo,
|
||||
* telefone_parente, telefone_responsavel, email_principal, email_alternativo, cep
|
||||
*
|
||||
* Regra do banco: CPF é armazenado como 11 dígitos (sem máscara).
|
||||
* Telefones são armazenados como dígitos apenas.
|
||||
*/
|
||||
|
||||
// ─── Utilidade base ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Remove tudo que não for dígito */
|
||||
export function digitsOnly(v) {
|
||||
return String(v ?? '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
// ─── CPF ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Valida CPF (com ou sem máscara).
|
||||
* Retorna false para sequências repetidas (111.111.111-11) e para dígitos inválidos.
|
||||
*/
|
||||
export function isValidCPF(v) {
|
||||
const d = digitsOnly(v)
|
||||
if (d.length !== 11) return false
|
||||
if (/^(\d)\1+$/.test(d)) return false // sequências iguais
|
||||
|
||||
const calcDV = (base) => {
|
||||
let sum = 0
|
||||
for (let i = 0; i < base.length; i++) sum += Number(base[i]) * (base.length + 1 - i)
|
||||
const mod = sum % 11
|
||||
return mod < 2 ? 0 : 11 - mod
|
||||
}
|
||||
|
||||
const dv1 = calcDV(d.slice(0, 9))
|
||||
if (Number(d[9]) !== dv1) return false
|
||||
|
||||
const dv2 = calcDV(d.slice(0, 10))
|
||||
if (Number(d[10]) !== dv2) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Formata CPF para exibição: 000.000.000-00 */
|
||||
export function fmtCPF(v) {
|
||||
const d = digitsOnly(v).slice(0, 11)
|
||||
if (!d) return ''
|
||||
return d
|
||||
.replace(/^(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/^(\d{3})\.(\d{3})(\d)/, '$1.$2.$3')
|
||||
.replace(/\.(\d{3})(\d)/, '.$1-$2')
|
||||
}
|
||||
|
||||
/** Gera um CPF válido (útil para testes/seed) */
|
||||
export function generateCPF() {
|
||||
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||
const n = Array.from({ length: 9 }, () => randInt(0, 9))
|
||||
|
||||
const calcDV = (base) => {
|
||||
let sum = 0
|
||||
for (let i = 0; i < base.length; i++) sum += base[i] * (base.length + 1 - i)
|
||||
const mod = sum % 11
|
||||
return mod < 2 ? 0 : 11 - mod
|
||||
}
|
||||
|
||||
const d1 = calcDV(n)
|
||||
const d2 = calcDV([...n, d1])
|
||||
const cpf = [...n, d1, d2].join('')
|
||||
|
||||
if (/^(\d)\1+$/.test(cpf)) return generateCPF()
|
||||
return cpf
|
||||
}
|
||||
|
||||
// ─── CNPJ ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Valida CNPJ (com ou sem máscara).
|
||||
* Rejeita sequências repetidas (00.000.000/0000-00).
|
||||
*/
|
||||
export function isValidCNPJ(v) {
|
||||
const d = digitsOnly(v)
|
||||
if (d.length !== 14) return false
|
||||
if (/^(\d)\1+$/.test(d)) return false
|
||||
|
||||
const calcDV = (base, weights) => {
|
||||
let sum = 0
|
||||
for (let i = 0; i < base.length; i++) sum += Number(base[i]) * weights[i]
|
||||
const mod = sum % 11
|
||||
return mod < 2 ? 0 : 11 - mod
|
||||
}
|
||||
|
||||
const w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
|
||||
const w2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
|
||||
|
||||
if (Number(d[12]) !== calcDV(d.slice(0, 12), w1)) return false
|
||||
if (Number(d[13]) !== calcDV(d.slice(0, 13), w2)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Formata CNPJ para exibição: 00.000.000/0000-00 */
|
||||
export function fmtCNPJ(v) {
|
||||
const d = digitsOnly(v).slice(0, 14)
|
||||
if (!d) return ''
|
||||
return d
|
||||
.replace(/^(\d{2})(\d)/, '$1.$2')
|
||||
.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3')
|
||||
.replace(/\.(\d{3})(\d)/, '.$1/$2')
|
||||
.replace(/(\d{4})(\d)/, '$1-$2')
|
||||
}
|
||||
|
||||
// ─── RG ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Formata RG para exibição: 00.000.000-0 */
|
||||
export function fmtRG(v) {
|
||||
if (!v) return ''
|
||||
const d = digitsOnly(v).slice(0, 9)
|
||||
if (!d) return ''
|
||||
return d
|
||||
.replace(/^(\d{2})(\d)/, '$1.$2')
|
||||
.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3')
|
||||
.replace(/\.(\d{3})(\d)/, '.$1-$2')
|
||||
}
|
||||
|
||||
// ─── Telefone ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Valida telefone brasileiro (com ou sem máscara, com ou sem DDD).
|
||||
* Aceita 10 dígitos (fixo) ou 11 dígitos (celular).
|
||||
*/
|
||||
export function isValidPhone(v) {
|
||||
const d = digitsOnly(v)
|
||||
return d.length === 10 || d.length === 11
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata telefone para exibição.
|
||||
* 11 dígitos → (XX) XXXXX-XXXX (celular)
|
||||
* 10 dígitos → (XX) XXXX-XXXX (fixo)
|
||||
*/
|
||||
export function fmtPhone(v) {
|
||||
const d = digitsOnly(v)
|
||||
if (!d) return ''
|
||||
if (d.length === 11) return d.replace(/^(\d{2})(\d{5})(\d{4})$/, '($1) $2-$3')
|
||||
if (d.length === 10) return d.replace(/^(\d{2})(\d{4})(\d{4})$/, '($1) $2-$3')
|
||||
return d
|
||||
}
|
||||
|
||||
// ─── Email ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Valida email (formato básico) */
|
||||
export function isValidEmail(v) {
|
||||
const s = String(v ?? '').trim()
|
||||
if (!s) return false
|
||||
return /.+@.+\..+/.test(s)
|
||||
}
|
||||
|
||||
// ─── CEP ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Valida CEP brasileiro: 8 dígitos */
|
||||
export function isValidCEP(v) {
|
||||
const d = digitsOnly(v)
|
||||
return d.length === 8
|
||||
}
|
||||
|
||||
/** Formata CEP para exibição: 00000-000 */
|
||||
export function fmtCEP(v) {
|
||||
const d = digitsOnly(v).slice(0, 8)
|
||||
if (!d) return ''
|
||||
return d.replace(/^(\d{5})(\d)/, '$1-$2')
|
||||
}
|
||||
|
||||
// ─── Sanitização para o banco ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converte valor formatado para apenas dígitos antes de salvar no banco.
|
||||
* Retorna null para valores vazios.
|
||||
*/
|
||||
export function sanitizeDigits(v) {
|
||||
const d = digitsOnly(v)
|
||||
return d || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte data de DD/MM/YYYY ou DD-MM-YYYY para YYYY-MM-DD (formato ISO para o banco).
|
||||
* Retorna null se inválido.
|
||||
*/
|
||||
export function toISODate(v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
const match = s.match(/^(\d{2})[/\-](\d{2})[/\-](\d{4})$/)
|
||||
if (!match) return null
|
||||
const [, dd, mm, yyyy] = match
|
||||
const date = new Date(`${yyyy}-${mm}-${dd}`)
|
||||
if (isNaN(date.getTime())) return null
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,22 +15,22 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
import { useLayout as _useLayout } from '@/layout/composables/layout';
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
const { setVariant } = _useLayout();
|
||||
|
||||
import Textarea from 'primevue/textarea';
|
||||
import InputMask from 'primevue/inputmask';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import InputMask from 'primevue/inputmask';
|
||||
import Select from 'primevue/select';
|
||||
import Textarea from 'primevue/textarea';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
@@ -50,6 +50,25 @@ const AVATAR_BUCKET = 'avatars';
|
||||
const saving = ref(false);
|
||||
const dirty = ref(false);
|
||||
|
||||
const fieldErrors = reactive({
|
||||
full_name: '',
|
||||
nickname: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
function clearErr(field) {
|
||||
fieldErrors[field] = '';
|
||||
}
|
||||
|
||||
function validateRequired() {
|
||||
const nameParts = form.full_name?.trim().split(/\s+/).filter(Boolean) || [];
|
||||
fieldErrors.full_name = nameParts.length === 0 ? 'Nome completo é obrigatório.' : nameParts.length < 2 ? 'Informe seu nome e sobrenome.' : '';
|
||||
fieldErrors.nickname = form.nickname?.trim() ? '' : 'Nome de exibição é obrigatório.';
|
||||
const digits = form.phone?.replace(/[^0-9]/g, '') || '';
|
||||
fieldErrors.phone = digits.length >= 10 ? '' : 'WhatsApp é obrigatório.';
|
||||
return !fieldErrors.full_name && !fieldErrors.nickname && !fieldErrors.phone;
|
||||
}
|
||||
|
||||
const openPassword = ref(false);
|
||||
const sendingPassword = ref(false);
|
||||
const passwordSent = ref(false);
|
||||
@@ -140,6 +159,59 @@ function markDirty() {
|
||||
dirty.value = true;
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Gamificação / Progresso
|
||||
----------------------------- */
|
||||
const profileFields = computed(() => [
|
||||
{ key: 'full_name', filled: !!form.full_name?.trim(), icon: 'pi pi-user', text: 'Preencha seu nome completo' },
|
||||
{ key: 'nickname', filled: !!form.nickname?.trim(), icon: 'pi pi-comment', text: 'Escolha um nome de exibição' },
|
||||
{ key: 'work_description', filled: !!form.work_description?.trim(), icon: 'pi pi-briefcase', text: 'Descreva seu trabalho' },
|
||||
{ key: 'avatar', filled: !!(form.avatar_url?.trim() || ui.avatarFile), icon: 'pi pi-image', text: 'Adicione uma foto' },
|
||||
{ key: 'bio', filled: !!form.bio?.trim(), icon: 'pi pi-pencil', text: 'Complete sua bio' },
|
||||
{ key: 'phone', filled: !!form.phone?.trim()?.replace(/[^0-9]/g, ''), icon: 'pi pi-whatsapp', text: 'Informe seu WhatsApp' },
|
||||
{ key: 'social', filled: !!(form.social_instagram || form.social_x || form.site_url || form.social_youtube || form.social_facebook || customSocials.value.length), icon: 'pi pi-share-alt', text: 'Adicione uma rede social' }
|
||||
]);
|
||||
|
||||
const profileProgress = computed(() => {
|
||||
const filled = profileFields.value.filter((f) => f.filled).length;
|
||||
return Math.round((filled / profileFields.value.length) * 100);
|
||||
});
|
||||
|
||||
const progressSuggestions = computed(() => profileFields.value.filter((f) => !f.filled));
|
||||
|
||||
const progressColor = computed(() => {
|
||||
if (profileProgress.value >= 80) return '#10b981';
|
||||
if (profileProgress.value >= 50) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
});
|
||||
|
||||
const gameLevels = [
|
||||
{ min: 0, max: 14, label: 'Iniciante', icon: '🌱', color: '#94a3b8' },
|
||||
{ min: 15, max: 28, label: 'Aprendiz', icon: '🌿', color: '#60a5fa' },
|
||||
{ min: 29, max: 42, label: 'Praticante', icon: '⚡', color: '#f59e0b' },
|
||||
{ min: 43, max: 57, label: 'Avançado', icon: '🔥', color: '#f97316' },
|
||||
{ min: 58, max: 71, label: 'Expert', icon: '💎', color: '#a78bfa' },
|
||||
{ min: 72, max: 85, label: 'Mestre', icon: '🏆', color: '#10b981' },
|
||||
{ min: 86, max: 100, label: 'Lendário', icon: '🌟', color: '#eab308' }
|
||||
];
|
||||
|
||||
const currentLevel = computed(() => gameLevels.find((l) => profileProgress.value >= l.min && profileProgress.value <= l.max) || gameLevels[0]);
|
||||
const nextLevel = computed(() => {
|
||||
const i = gameLevels.indexOf(currentLevel.value);
|
||||
return i < gameLevels.length - 1 ? gameLevels[i + 1] : null;
|
||||
});
|
||||
const xpToNext = computed(() => (nextLevel.value ? nextLevel.value.min - profileProgress.value : 0));
|
||||
|
||||
const badges = computed(() => [
|
||||
{ key: 'name', earned: !!form.full_name?.trim(), icon: '👤', label: 'Identificado' },
|
||||
{ key: 'nick', earned: !!form.nickname?.trim(), icon: '✏️', label: 'Apelido' },
|
||||
{ key: 'work', earned: !!form.work_description?.trim(), icon: '💼', label: 'Profissional' },
|
||||
{ key: 'photo', earned: !!(form.avatar_url?.trim() || ui.avatarFile), icon: '📷', label: 'Fotogênico' },
|
||||
{ key: 'bio', earned: !!form.bio?.trim(), icon: '📝', label: 'Eloquente' },
|
||||
{ key: 'phone', earned: !!form.phone?.trim()?.replace(/[^0-9]/g, ''), icon: '📱', label: 'Conectado' },
|
||||
{ key: 'social', earned: !!(form.social_instagram || form.social_x || form.site_url || form.social_youtube || form.social_facebook || customSocials.value.length), icon: '🌐', label: 'Social' }
|
||||
]);
|
||||
|
||||
/* ----------------------------
|
||||
Cores e preset
|
||||
----------------------------- */
|
||||
@@ -500,6 +572,12 @@ async function loadProfile() {
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if (!validateRequired()) {
|
||||
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Preencha nome completo, nome de exibição e WhatsApp antes de salvar.', life: 4000 });
|
||||
// Rola até a seção Conta para o usuário ver os erros
|
||||
document.getElementById('conta')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (ui.avatarFile) {
|
||||
@@ -677,11 +755,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<!-- ── HERO ────────────────────────────────────────────── -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<div ref="heroSentinelRef" class="p-2" />
|
||||
<div
|
||||
ref="heroEl"
|
||||
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
:style="{ top: 'var(--layout-sticky-top, 55px)' }"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': heroStuck }"
|
||||
>
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
@@ -717,11 +795,66 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── PROGRESSO / GAMIFICAÇÃO ──────────────────────────── -->
|
||||
<div class="px-3 md:px-4 mb-4">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 space-y-4">
|
||||
<!-- Nível + barra -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="shrink-0 w-12 h-12 rounded-xl flex items-center justify-center text-2xl border" :style="{ backgroundColor: currentLevel.color + '18', borderColor: currentLevel.color + '40' }" v-tooltip.top="currentLevel.label">
|
||||
{{ currentLevel.icon }}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[1rem] font-bold text-[var(--text-color)]">{{ currentLevel.label }}</span>
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" :style="{ backgroundColor: currentLevel.color + '20', color: currentLevel.color }">Nível {{ gameLevels.indexOf(currentLevel) + 1 }}</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold" :style="{ color: progressColor }">{{ profileProgress }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-2.5 rounded-full bg-[var(--surface-ground)] overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-700 ease-out relative overflow-hidden" :style="{ width: profileProgress + '%', backgroundColor: currentLevel.color }">
|
||||
<div class="absolute inset-0 prof-shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--text-color-secondary)] mt-1.5 m-0">
|
||||
<span v-if="nextLevel">
|
||||
<span class="font-medium" :style="{ color: nextLevel.color }">{{ xpToNext }}% até {{ nextLevel.label }} {{ nextLevel.icon }}</span>
|
||||
— preencha mais dados para evoluir
|
||||
</span>
|
||||
<span v-else class="font-semibold" style="color: #10b981">🎉 Perfil 100% completo!</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badges -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider mb-2">Conquistas</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<div v-for="badge in badges" :key="badge.key" class="prof-badge" :class="badge.earned ? 'prof-badge--earned' : 'prof-badge--locked'" v-tooltip.top="badge.earned ? badge.label : 'Bloqueado — ' + badge.label">
|
||||
<span class="text-base leading-none">{{ badge.icon }}</span>
|
||||
<span class="text-xs font-medium leading-none">{{ badge.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dicas do que falta -->
|
||||
<div v-if="progressSuggestions.length" class="flex flex-wrap gap-2 pt-1 border-t border-[var(--surface-border)]">
|
||||
<span v-for="(tip, i) in progressSuggestions" :key="i" class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full bg-[var(--surface-ground)] text-[var(--text-color-secondary)] border border-[var(--surface-border)]">
|
||||
<i :class="tip.icon" class="text-[0.65rem]" />
|
||||
{{ tip.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── GRID ─────────────────────────────────────────────── -->
|
||||
<div class="px-3 md:px-4 pb-8 grid grid-cols-12 gap-4 md:gap-5">
|
||||
<!-- Sidebar -->
|
||||
<div class="col-span-12 lg:col-span-3">
|
||||
<div class="sticky top-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 flex flex-col gap-3">
|
||||
<div class="sticky rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 flex flex-col gap-3" :style="{ top: 'var(--layout-sticky-top, 140px)' }">
|
||||
<!-- Mini user -->
|
||||
<div class="flex items-center gap-3 pb-1">
|
||||
<div class="relative shrink-0">
|
||||
@@ -788,19 +921,41 @@ onBeforeUnmount(() => {
|
||||
<!-- Nome completo -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="prof_name" v-model="form.full_name" class="w-full" autocomplete="name" @input="markDirty" />
|
||||
<label for="prof_name">Nome completo</label>
|
||||
<InputText
|
||||
id="prof_name"
|
||||
v-model="form.full_name"
|
||||
class="w-full"
|
||||
autocomplete="name"
|
||||
:invalid="!!fieldErrors.full_name"
|
||||
@input="
|
||||
markDirty();
|
||||
clearErr('full_name');
|
||||
"
|
||||
/>
|
||||
<label for="prof_name">Nome completo <span class="text-red-400">*</span></label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Aparece no menu, cabeçalhos e registros.</div>
|
||||
<Message v-if="fieldErrors.full_name" severity="error" size="small" variant="simple" class="mt-1">{{ fieldErrors.full_name }}</Message>
|
||||
<div v-else class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Aparece no menu, cabeçalhos e registros.</div>
|
||||
</div>
|
||||
|
||||
<!-- Como a Agência PSI deveria te chamar? -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="prof_nickname" v-model="form.nickname" class="w-full" autocomplete="nickname" @input="markDirty" />
|
||||
<label for="prof_nickname">Como a Agência PSI deveria te chamar?</label>
|
||||
<InputText
|
||||
id="prof_nickname"
|
||||
v-model="form.nickname"
|
||||
class="w-full"
|
||||
autocomplete="nickname"
|
||||
:invalid="!!fieldErrors.nickname"
|
||||
@input="
|
||||
markDirty();
|
||||
clearErr('nickname');
|
||||
"
|
||||
/>
|
||||
<label for="prof_nickname">Como a Agência PSI deveria te chamar? <span class="text-red-400">*</span></label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Apelido ou nome preferido para comunicação.</div>
|
||||
<Message v-if="fieldErrors.nickname" severity="error" size="small" variant="simple" class="mt-1">{{ fieldErrors.nickname }}</Message>
|
||||
<div v-else class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Apelido ou nome preferido para comunicação.</div>
|
||||
</div>
|
||||
|
||||
<!-- O que melhor descreve seu trabalho? -->
|
||||
@@ -847,10 +1002,25 @@ onBeforeUnmount(() => {
|
||||
<!-- Whatsapp -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="prof_phone" v-model="form.phone" class="w-full" mask="(99) 99999-9999" :autoClear="false" @update:modelValue="markDirty" />
|
||||
<label for="prof_phone">Whatsapp</label>
|
||||
<InputMask
|
||||
id="prof_phone"
|
||||
v-model="form.phone"
|
||||
class="w-full"
|
||||
mask="(99) 99999-9999"
|
||||
:autoClear="false"
|
||||
:invalid="!!fieldErrors.phone"
|
||||
@update:modelValue="
|
||||
markDirty();
|
||||
clearErr('phone');
|
||||
"
|
||||
/>
|
||||
<label for="prof_phone">WhatsApp <span class="text-red-400">*</span></label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Opcional.</div>
|
||||
<Message v-if="fieldErrors.phone" severity="error" size="small" variant="simple" class="mt-1">{{ fieldErrors.phone }}</Message>
|
||||
<div v-else class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
||||
<i class="pi pi-lock text-[0.7rem] opacity-60" />
|
||||
<span>Usado apenas para notificações importantes.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1333,6 +1503,45 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Shimmer na barra de progresso ─────────────────────── */
|
||||
@keyframes prof-shimmer-anim {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
.prof-shimmer {
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.28) 50%, transparent 100%);
|
||||
width: 55%;
|
||||
animation: prof-shimmer-anim 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Badges de conquista ────────────────────────────────── */
|
||||
.prof-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid;
|
||||
transition: all 0.18s ease;
|
||||
cursor: default;
|
||||
}
|
||||
.prof-badge--earned {
|
||||
background-color: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
}
|
||||
.prof-badge--locked {
|
||||
background-color: var(--surface-ground);
|
||||
border-color: var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
filter: grayscale(1);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ─── Sidebar nav ───────────────────────────────────────── */
|
||||
.nav-link {
|
||||
color: var(--text-color-secondary);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ import Select from 'primevue/select';
|
||||
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { digitsOnly, isValidEmail, toISODate, generateCPF, fmtCPF } from '@/utils/validators';
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
@@ -48,37 +49,6 @@ function cleanStr(v) {
|
||||
const s = String(v ?? '').trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
function digitsOnly(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '');
|
||||
return d ? d : null;
|
||||
}
|
||||
function isValidEmail(v) {
|
||||
const s = String(v ?? '').trim();
|
||||
if (!s) return false;
|
||||
return /.+@.+\..+/.test(s);
|
||||
}
|
||||
|
||||
// DD-MM-YYYY -> YYYY-MM-DD
|
||||
function parseDDMMYYYY(s) {
|
||||
const str = String(s || '').trim();
|
||||
const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(str);
|
||||
if (!m) return null;
|
||||
const dd = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
const yyyy = Number(m[3]);
|
||||
const dt = new Date(yyyy, mm - 1, dd);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
if (dt.getFullYear() !== yyyy || dt.getMonth() !== mm - 1 || dt.getDate() !== dd) return null;
|
||||
return dt;
|
||||
}
|
||||
function toISODateFromDDMMYYYY(s) {
|
||||
const dt = parseDDMMYYYY(s);
|
||||
if (!dt) return null;
|
||||
const yyyy = String(dt.getFullYear()).padStart(4, '0');
|
||||
const mm = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(dt.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Mock fill (Preencher tudo)
|
||||
@@ -112,18 +82,8 @@ function randomPhoneBR() {
|
||||
return `(${ddd}) ${p1}-${p2}`;
|
||||
}
|
||||
|
||||
function generateCPF() {
|
||||
const n = Array.from({ length: 9 }, () => randInt(0, 9));
|
||||
const calcDV = (base) => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < base.length; i++) sum += base[i] * (base.length + 1 - i);
|
||||
const mod = sum % 11;
|
||||
return mod < 2 ? 0 : 11 - mod;
|
||||
};
|
||||
const d1 = calcDV(n);
|
||||
const d2 = calcDV([...n, d1]);
|
||||
const cpf = [...n, d1, d2].join('');
|
||||
return cpf.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, '$1.$2.$3-$4');
|
||||
function generateCPFFormatted() {
|
||||
return fmtCPF(generateCPF());
|
||||
}
|
||||
|
||||
function generateRG() {
|
||||
@@ -170,7 +130,7 @@ function preencherMock() {
|
||||
form.telefone = randomPhoneBR();
|
||||
form.telefone_alternativo = maybe(0.35) ? randomPhoneBR() : '';
|
||||
|
||||
form.cpf = generateCPF();
|
||||
form.cpf = generateCPFFormatted();
|
||||
form.rg = generateRG();
|
||||
|
||||
form.observacoes = maybe(0.5) ? 'Cadastro realizado via link externo.' : 'Tenho disponibilidade no período da noite.';
|
||||
@@ -199,7 +159,7 @@ function preencherMock() {
|
||||
const temResponsavel = maybe(0.35);
|
||||
form.nome_responsavel = temResponsavel ? `${pick(first)} ${pick(last)} ${pick(last)}` : '';
|
||||
form.telefone_responsavel = temResponsavel ? randomPhoneBR() : '';
|
||||
form.cpf_responsavel = temResponsavel ? generateCPF() : '';
|
||||
form.cpf_responsavel = temResponsavel ? generateCPFFormatted() : '';
|
||||
form.observacao_responsavel = temResponsavel ? 'Responsável ciente e de acordo com o cadastro.' : '';
|
||||
form.cobranca_no_responsavel = temResponsavel ? maybe(0.5) : false;
|
||||
|
||||
@@ -494,7 +454,7 @@ async function enviar() {
|
||||
return;
|
||||
}
|
||||
|
||||
const isoBirth = toISODateFromDDMMYYYY(form.data_nascimento);
|
||||
const isoBirth = toISODate(form.data_nascimento);
|
||||
if (cleanStr(form.data_nascimento) && !isoBirth) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Data de nascimento inválida (use DD-MM-AAAA).', life: 2500 });
|
||||
openPanel(0);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/views/pages/public/SharedDocumentPage.vue
|
||||
| Pagina publica para visualizar documento compartilhado via link temporario.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { validateShareToken } from '@/services/DocumentShareLinks.service'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const doc = ref(null)
|
||||
const previewUrl = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const token = route.params.token
|
||||
if (!token) {
|
||||
error.value = 'Link inválido.'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateShareToken(token)
|
||||
if (!result?.document) {
|
||||
error.value = 'Este link expirou, atingiu o limite de acessos ou é inválido.'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
doc.value = result.document
|
||||
|
||||
// Gerar URL assinada para download/visualizacao
|
||||
const bucket = result.document.storage_bucket || 'documents'
|
||||
const { data, error: storageErr } = await supabase.storage
|
||||
.from(bucket)
|
||||
.createSignedUrl(result.document.bucket_path, 300) // 5 min
|
||||
|
||||
if (storageErr) throw storageErr
|
||||
previewUrl.value = data?.signedUrl || ''
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao acessar o documento.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function downloadFile() {
|
||||
if (!previewUrl.value) return
|
||||
const a = document.createElement('a')
|
||||
a.href = previewUrl.value
|
||||
a.download = doc.value?.nome_original || 'documento'
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const isPdf = () => doc.value?.mime_type === 'application/pdf'
|
||||
const isImage = () => String(doc.value?.mime_type || '').startsWith('image/')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-3xl">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
|
||||
<i class="pi pi-spinner pi-spin text-3xl text-gray-400 mb-3" />
|
||||
<p class="text-sm text-gray-500">Validando link...</p>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-else-if="error" class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center mb-4">
|
||||
<i class="pi pi-times-circle text-3xl text-red-400" />
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-2">Documento indisponível</h2>
|
||||
<p class="text-sm text-gray-500 max-w-md">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Documento -->
|
||||
<div v-else class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<i :class="isPdf() ? 'pi pi-file-pdf text-red-500' : isImage() ? 'pi pi-image text-blue-500' : 'pi pi-file text-gray-500'" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800 truncate">{{ doc?.nome_original }}</div>
|
||||
<div class="text-xs text-gray-400">Documento compartilhado via AgênciaPSI</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||
@click="downloadFile"
|
||||
>
|
||||
<i class="pi pi-download text-xs" />
|
||||
Baixar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="bg-gray-100">
|
||||
<iframe
|
||||
v-if="isPdf()"
|
||||
:src="previewUrl"
|
||||
class="w-full border-0"
|
||||
style="height: 80vh;"
|
||||
/>
|
||||
<div v-else-if="isImage()" class="flex items-center justify-center p-6">
|
||||
<img :src="previewUrl" :alt="doc?.nome_original" class="max-w-full max-h-[80vh] object-contain rounded" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<i class="pi pi-file text-4xl mb-3" />
|
||||
<p class="text-sm">Pré-visualização não disponível para este tipo de arquivo.</p>
|
||||
<p class="text-xs mt-1">Clique em "Baixar" para acessar o documento.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-3 text-center text-xs text-gray-400 border-t border-gray-200">
|
||||
Compartilhado com segurança via AgênciaPSI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,523 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/saas/SaasDocumentTemplatesPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
| Gestao de templates globais de documentos pelo SaaS admin.
|
||||
| Templates globais (is_global = true) ficam disponiveis para todos os tenants.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { extractVariablesFromHtml, TEMPLATE_VARIABLES } from '@/services/DocumentTemplates.service'
|
||||
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── State ───────────────────────────────────────────────────
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const q = ref('')
|
||||
|
||||
const showDlg = ref(false)
|
||||
const saving = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const activeTab = ref('editor')
|
||||
|
||||
const TIPOS = [
|
||||
{ value: 'declaracao_comparecimento', label: 'Declaração de comparecimento' },
|
||||
{ value: 'atestado_psicologico', label: 'Atestado psicológico' },
|
||||
{ value: 'relatorio_acompanhamento', label: 'Relatório de acompanhamento' },
|
||||
{ value: 'recibo_pagamento', label: 'Recibo de pagamento' },
|
||||
{ value: 'termo_consentimento', label: 'Termo de consentimento (TCLE)' },
|
||||
{ value: 'encaminhamento', label: 'Encaminhamento' },
|
||||
{ value: 'contrato_servicos', label: 'Contrato de prestação de serviços' },
|
||||
{ value: 'tcle', label: 'TCLE' },
|
||||
{ value: 'autorizacao_menor', label: 'Autorização para menor' },
|
||||
{ value: 'laudo_psicologico', label: 'Laudo psicológico' },
|
||||
{ value: 'parecer_psicologico', label: 'Parecer psicológico' },
|
||||
{ value: 'termo_sigilo', label: 'Termo de sigilo' },
|
||||
{ value: 'declaracao_inicio_tratamento', label: 'Declaração de início de tratamento' },
|
||||
{ value: 'termo_alta', label: 'Termo de alta terapêutica' },
|
||||
{ value: 'tcle_online', label: 'Consentimento atendimento online' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
]
|
||||
|
||||
const form = ref(resetForm())
|
||||
|
||||
function resetForm() {
|
||||
return {
|
||||
id: null,
|
||||
nome_template: '',
|
||||
tipo: 'outro',
|
||||
descricao: '',
|
||||
corpo_html: '',
|
||||
cabecalho_html: '',
|
||||
rodape_html: '',
|
||||
variaveis: [],
|
||||
logo_url: '',
|
||||
ativo: true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filtro ───────────────────────────────────────────────────
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const term = String(q.value || '').trim().toLowerCase()
|
||||
if (!term) return rows.value
|
||||
return rows.value.filter(r =>
|
||||
[r.nome_template, r.tipo, r.descricao].some(s =>
|
||||
String(s || '').toLowerCase().includes(term)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// ── Fetch ───────────────────────────────────────────────────
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('document_templates')
|
||||
.select('*')
|
||||
.eq('is_global', true)
|
||||
.order('nome_template', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
rows.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
|
||||
// ── Dialog ──────────────────────────────────────────────────
|
||||
|
||||
function openCreate() {
|
||||
form.value = resetForm()
|
||||
isEdit.value = false
|
||||
activeTab.value = 'editor'
|
||||
showDlg.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
form.value = {
|
||||
id: row.id,
|
||||
nome_template: row.nome_template || '',
|
||||
tipo: row.tipo || 'outro',
|
||||
descricao: row.descricao || '',
|
||||
corpo_html: row.corpo_html || '',
|
||||
cabecalho_html: row.cabecalho_html || '',
|
||||
rodape_html: row.rodape_html || '',
|
||||
variaveis: row.variaveis || [],
|
||||
logo_url: row.logo_url || '',
|
||||
ativo: row.ativo ?? true
|
||||
}
|
||||
isEdit.value = true
|
||||
activeTab.value = 'editor'
|
||||
showDlg.value = true
|
||||
}
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
const nome = String(form.value.nome_template || '').trim()
|
||||
if (!nome) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome do template é obrigatório.' })
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// Auto-extrair variaveis do HTML
|
||||
const allHtml = (form.value.corpo_html || '') + (form.value.cabecalho_html || '') + (form.value.rodape_html || '')
|
||||
const vars = extractVariablesFromHtml(allHtml)
|
||||
|
||||
const payload = {
|
||||
nome_template: nome,
|
||||
tipo: form.value.tipo || 'outro',
|
||||
descricao: form.value.descricao || null,
|
||||
corpo_html: form.value.corpo_html || '',
|
||||
cabecalho_html: form.value.cabecalho_html || null,
|
||||
rodape_html: form.value.rodape_html || null,
|
||||
variaveis: vars,
|
||||
logo_url: form.value.logo_url || null,
|
||||
is_global: true,
|
||||
ativo: form.value.ativo,
|
||||
tenant_id: null,
|
||||
owner_id: null
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const { error } = await supabase
|
||||
.from('document_templates')
|
||||
.update(payload)
|
||||
.eq('id', form.value.id)
|
||||
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: nome, life: 2000 })
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('document_templates')
|
||||
.insert(payload)
|
||||
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: nome, life: 2000 })
|
||||
}
|
||||
|
||||
showDlg.value = false
|
||||
fetchAll()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle ativo ────────────────────────────────────────────
|
||||
|
||||
async function toggleAtivo(row) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('document_templates')
|
||||
.update({ ativo: !row.ativo })
|
||||
.eq('id', row.id)
|
||||
|
||||
if (error) throw error
|
||||
row.ativo = !row.ativo
|
||||
toast.add({ severity: 'info', summary: row.ativo ? 'Ativado' : 'Desativado', detail: row.nome_template, life: 2000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────
|
||||
|
||||
function onDelete(row) {
|
||||
confirm.require({
|
||||
message: `Excluir permanentemente "${row.nome_template}"? Essa ação não pode ser desfeita.`,
|
||||
header: 'Excluir template global',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('document_templates')
|
||||
.delete()
|
||||
.eq('id', row.id)
|
||||
|
||||
if (error) throw error
|
||||
rows.value = rows.value.filter(r => r.id !== row.id)
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function tipoLabel(tipo) {
|
||||
return TIPOS.find(t => t.value === tipo)?.label || tipo
|
||||
}
|
||||
|
||||
// ── Preview com dados ficticios ─────────────────────────────
|
||||
|
||||
const SAMPLE = {
|
||||
paciente_nome: 'Maria Silva Santos',
|
||||
paciente_cpf: '123.456.789-00',
|
||||
paciente_data_nascimento: '15/03/1990',
|
||||
data_sessao: '28/03/2026',
|
||||
hora_inicio: '14:00',
|
||||
hora_fim: '14:50',
|
||||
terapeuta_nome: 'Dr. João Oliveira',
|
||||
terapeuta_crp: '06/12345',
|
||||
clinica_nome: 'Clínica Exemplo',
|
||||
clinica_endereco: 'Av. São Carlos, 500, Centro, São Carlos/SP',
|
||||
clinica_telefone: '(16) 3333-1111',
|
||||
clinica_cnpj: '12.345.678/0001-00',
|
||||
valor: 'R$ 200,00',
|
||||
valor_extenso: 'duzentos reais',
|
||||
forma_pagamento: 'PIX',
|
||||
data_atual: new Date().toLocaleDateString('pt-BR'),
|
||||
data_atual_extenso: '29 de março de 2026',
|
||||
cidade_estado: 'São Carlos/SP'
|
||||
}
|
||||
|
||||
function previewReplace(html) {
|
||||
return String(html || '').replace(/\{\{(\w+)\}\}/g, (m, k) =>
|
||||
SAMPLE[k] !== undefined
|
||||
? `<span style="background:#fef3c7;padding:1px 4px;border-radius:3px;">${SAMPLE[k]}</span>`
|
||||
: `<span style="background:#fee2e2;padding:1px 4px;border-radius:3px;">${m}</span>`
|
||||
)
|
||||
}
|
||||
|
||||
// ── Variaveis agrupadas ─────────────────────────────────────
|
||||
|
||||
const variablesGrouped = computed(() => {
|
||||
const groups = {}
|
||||
for (const v of TEMPLATE_VARIABLES) {
|
||||
if (!groups[v.grupo]) groups[v.grupo] = []
|
||||
groups[v.grupo].push(v)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const cursorField = ref('corpo_html')
|
||||
const editorCabecalho = ref(null)
|
||||
const editorCorpo = ref(null)
|
||||
const editorRodape = ref(null)
|
||||
|
||||
function insertVariable(key) {
|
||||
const tag = `{{${key}}}`
|
||||
const editorMap = {
|
||||
cabecalho_html: editorCabecalho,
|
||||
corpo_html: editorCorpo,
|
||||
rodape_html: editorRodape
|
||||
}
|
||||
const editorRef = editorMap[cursorField.value]
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag)
|
||||
} else {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-6 max-w-[1200px] mx-auto">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">Templates de Documentos</h1>
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
Templates globais disponíveis para todos os tenants (is_global = true)
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openCreate" />
|
||||
<Button icon="pi pi-refresh" text rounded size="small" @click="fetchAll" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca -->
|
||||
<div class="mb-4">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" placeholder="Buscar template..." class="!w-[300px]" size="small" />
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<DataTable
|
||||
v-else
|
||||
:value="filteredRows"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
class="text-sm"
|
||||
:rowClass="(r) => !r.ativo ? 'opacity-50' : ''"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-center py-8 text-[var(--text-color-secondary)]">Nenhum template global cadastrado.</div>
|
||||
</template>
|
||||
|
||||
<Column field="nome_template" header="Nome" sortable style="min-width: 200px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-file text-primary" />
|
||||
<span class="font-medium">{{ data.nome_template }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="tipo" header="Tipo" sortable style="min-width: 180px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="tipoLabel(data.tipo)" severity="info" class="text-xs" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="variaveis" header="Variáveis" style="min-width: 100px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ data.variaveis?.length || 0 }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="ativo" header="Status" style="width: 100px" bodyClass="text-center">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data.ativo ? 'Ativo' : 'Inativo'"
|
||||
:severity="data.ativo ? 'success' : 'danger'"
|
||||
class="text-xs cursor-pointer"
|
||||
@click="toggleAtivo(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="width: 120px" bodyClass="text-center">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button icon="pi pi-pencil" text rounded size="small" @click="openEdit(data)" v-tooltip.top="'Editar'" />
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="onDelete(data)" v-tooltip.top="'Excluir'" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- Dialog Create/Edit -->
|
||||
<Dialog
|
||||
v-model:visible="showDlg"
|
||||
modal
|
||||
maximizable
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
class="w-[65rem]"
|
||||
:breakpoints="{ '1199px': '95vw', '768px': '98vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
|
||||
<i class="pi pi-file-edit text-primary" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">{{ isEdit ? 'Editar' : 'Novo' }} template global</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Visível para todos os tenants</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Nome e tipo -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_200px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select v-model="form.tipo" :options="TIPOS" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_100px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição</label>
|
||||
<InputText v-model="form.descricao" placeholder="Breve descrição" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Status</label>
|
||||
<Select v-model="form.ativo" :options="[{ value: true, label: 'Ativo' }, { value: false, label: 'Inativo' }]" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex items-center gap-1 border-b border-[var(--surface-border)]">
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'editor' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)]'"
|
||||
@click="activeTab = 'editor'"
|
||||
>Editor</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'preview' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)]'"
|
||||
@click="activeTab = 'preview'"
|
||||
>Preview</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="activeTab === 'editor'" class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel de variaveis -->
|
||||
<div class="w-full lg:w-[200px] flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">Variáveis</div>
|
||||
<div class="text-[0.6rem] text-[var(--text-color-secondary)] mb-2">Clique para inserir</div>
|
||||
<div class="flex flex-col gap-2.5 max-h-[500px] overflow-y-auto pr-1">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
|
||||
<div class="text-[0.6rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">{{ grupo }}</div>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="text-left text-xs px-1.5 py-0.5 rounded hover:bg-primary/10 hover:text-primary transition-colors truncate"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
{{ v.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
|
||||
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
|
||||
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="previewReplace(form.cabecalho_html)" />
|
||||
<div class="min-h-[300px]" v-html="previewReplace(form.corpo_html)" />
|
||||
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="previewReplace(form.rodape_html)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button label="Cancelar" text @click="showDlg = false" :disabled="saving" />
|
||||
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
@@ -256,7 +256,7 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<div ref="heroSentinelRef" class="p-2" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
|
||||
@@ -417,7 +417,7 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<div ref="heroSentinelRef" class="p-2" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
|
||||
@@ -350,7 +350,7 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<div ref="heroSentinelRef" class="p-2" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
|
||||
@@ -433,7 +433,7 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<div ref="heroSentinelRef" class="p-2" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
|
||||
@@ -451,7 +451,7 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<div ref="heroSentinelRef" class="p-2" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
|
||||
@@ -612,7 +612,7 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<div ref="heroSentinelRef" class="p-2" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
|
||||
Reference in New Issue
Block a user