Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* session.spec.js
|
||||
*
|
||||
* Cobre o módulo de sessão global — foco nas race conditions documentadas
|
||||
* no próprio session.js (singleflight, SIGNED_IN redundante, TOKEN_REFRESHED).
|
||||
*
|
||||
* Mock do supabase: capturamos o callback de onAuthStateChange pra disparar
|
||||
* eventos manualmente e observar o state dos refs reativos.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Captura do callback de onAuthStateChange (setado no listenAuthChanges)
|
||||
let authCallback = null;
|
||||
|
||||
// Mock configurável de getSession (pode trocar em cada teste via mockImplementation)
|
||||
const getSessionMock = vi.fn();
|
||||
const profileSingleMock = vi.fn();
|
||||
const saasMaybeSingleMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => {
|
||||
const from = vi.fn((table) => {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle: table === 'saas_admins' ? saasMaybeSingleMock : profileSingleMock,
|
||||
single: vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: getSessionMock,
|
||||
onAuthStateChange: vi.fn((cb) => {
|
||||
authCallback = cb;
|
||||
return { data: { subscription: { unsubscribe: vi.fn() } } };
|
||||
})
|
||||
},
|
||||
from
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/support/supportLogger', () => ({
|
||||
logAuth: vi.fn(),
|
||||
logError: vi.fn()
|
||||
}));
|
||||
|
||||
// Importa depois dos mocks
|
||||
const session = await import('../session.js');
|
||||
|
||||
beforeEach(() => {
|
||||
// Reseta state dos refs e mocks a cada teste
|
||||
session.sessionUser.value = null;
|
||||
session.sessionRole.value = null;
|
||||
session.sessionIsSaasAdmin.value = false;
|
||||
session.sessionReady.value = false;
|
||||
session.sessionRefreshing.value = false;
|
||||
|
||||
// Desfaz listenAuthChanges de teste anterior pra permitir re-registro
|
||||
session.stopAuthChanges();
|
||||
|
||||
authCallback = null;
|
||||
getSessionMock.mockReset();
|
||||
profileSingleMock.mockReset();
|
||||
saasMaybeSingleMock.mockReset();
|
||||
|
||||
// defaults razoáveis
|
||||
profileSingleMock.mockResolvedValue({ data: { role: 'therapist' }, error: null });
|
||||
saasMaybeSingleMock.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
describe('initSession — boot inicial', () => {
|
||||
it('sem sessão → zera user/role/saasAdmin', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: null }, error: null });
|
||||
|
||||
await session.initSession({ initial: true });
|
||||
|
||||
expect(session.sessionUser.value).toBe(null);
|
||||
expect(session.sessionRole.value).toBe(null);
|
||||
expect(session.sessionIsSaasAdmin.value).toBe(false);
|
||||
expect(session.sessionReady.value).toBe(true);
|
||||
expect(session.sessionRefreshing.value).toBe(false);
|
||||
});
|
||||
|
||||
it('com sessão → hydrata user + busca role', async () => {
|
||||
getSessionMock.mockResolvedValue({
|
||||
data: { session: { user: { id: 'uid-1' } } },
|
||||
error: null
|
||||
});
|
||||
profileSingleMock.mockResolvedValue({ data: { role: 'clinic_admin' }, error: null });
|
||||
|
||||
await session.initSession({ initial: true });
|
||||
|
||||
expect(session.sessionUser.value?.id).toBe('uid-1');
|
||||
expect(session.sessionRole.value).toBe('clinic_admin');
|
||||
expect(session.sessionReady.value).toBe(true);
|
||||
});
|
||||
|
||||
it('erro em getSession → state zerado (não propaga)', async () => {
|
||||
getSessionMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await session.initSession({ initial: true });
|
||||
|
||||
expect(session.sessionUser.value).toBe(null);
|
||||
expect(session.sessionRole.value).toBe(null);
|
||||
expect(session.sessionReady.value).toBe(true); // ainda marca ready pra não travar o guard
|
||||
});
|
||||
|
||||
it('singleflight: 2 chamadas concorrentes fazem apenas 1 getSession', async () => {
|
||||
let resolveGet;
|
||||
getSessionMock.mockImplementation(() => new Promise((resolve) => { resolveGet = resolve; }));
|
||||
|
||||
const p1 = session.initSession({ initial: true });
|
||||
const p2 = session.initSession({ initial: true });
|
||||
|
||||
// segunda chamada deve ter retornado a mesma promise (sem disparar getSession de novo)
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveGet({ data: { session: null }, error: null });
|
||||
await Promise.all([p1, p2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshSession — evita corrida', () => {
|
||||
it('não dispara se já está refreshing', async () => {
|
||||
session.sessionRefreshing.value = true;
|
||||
|
||||
await session.refreshSession();
|
||||
|
||||
expect(getSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sem sessão → não zera state existente (SIGNED_OUT cuida)', async () => {
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
getSessionMock.mockResolvedValue({ data: { session: null }, error: null });
|
||||
|
||||
await session.refreshSession();
|
||||
|
||||
// State preservado — refreshSession não é quem zera (é SIGNED_OUT)
|
||||
expect(session.sessionUser.value?.id).toBe('uid-1');
|
||||
expect(session.sessionRole.value).toBe('therapist');
|
||||
});
|
||||
|
||||
it('mesma sessão consistente → no-op', async () => {
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
getSessionMock.mockResolvedValue({
|
||||
data: { session: { user: { id: 'uid-1' } } },
|
||||
error: null
|
||||
});
|
||||
|
||||
await session.refreshSession();
|
||||
|
||||
// initSession não foi chamado de novo (state já era consistente)
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1); // só o refreshSession próprio
|
||||
});
|
||||
});
|
||||
|
||||
describe('listenAuthChanges — callbacks de auth', () => {
|
||||
it('SIGNED_OUT zera state + chama callback', async () => {
|
||||
const onOut = vi.fn();
|
||||
session.setOnSignedOut(onOut);
|
||||
session.listenAuthChanges();
|
||||
|
||||
// simula state previamente hydratado
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
session.sessionIsSaasAdmin.value = true;
|
||||
session.sessionRefreshing.value = true;
|
||||
|
||||
expect(authCallback).toBeTypeOf('function');
|
||||
await authCallback('SIGNED_OUT', null);
|
||||
|
||||
expect(session.sessionUser.value).toBe(null);
|
||||
expect(session.sessionRole.value).toBe(null);
|
||||
expect(session.sessionIsSaasAdmin.value).toBe(false);
|
||||
expect(session.sessionRefreshing.value).toBe(false);
|
||||
expect(session.sessionReady.value).toBe(true);
|
||||
expect(onOut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIGNED_IN com mesmo user (redundante) é ignorado', async () => {
|
||||
session.listenAuthChanges();
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
session.sessionReady.value = true;
|
||||
|
||||
await authCallback('SIGNED_IN', { user: { id: 'uid-1' } });
|
||||
|
||||
// Não rehidratou — profileSingleMock não foi chamado
|
||||
expect(profileSingleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SIGNED_IN com user diferente → hydrata novo', async () => {
|
||||
session.listenAuthChanges();
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
session.sessionReady.value = true;
|
||||
|
||||
profileSingleMock.mockResolvedValue({ data: { role: 'clinic_admin' }, error: null });
|
||||
|
||||
await authCallback('SIGNED_IN', { user: { id: 'uid-2' } });
|
||||
|
||||
expect(session.sessionUser.value?.id).toBe('uid-2');
|
||||
expect(session.sessionRole.value).toBe('clinic_admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopAuthChanges — cleanup', () => {
|
||||
it('unsubscribe é chamado', () => {
|
||||
session.listenAuthChanges();
|
||||
session.stopAuthChanges();
|
||||
|
||||
// não deve lançar erro se chamar de novo
|
||||
expect(() => session.stopAuthChanges()).not.toThrow();
|
||||
});
|
||||
});
|
||||
+42
-5
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logAuth, logError } from '@/support/supportLogger';
|
||||
|
||||
/**
|
||||
* ⚠️ IMPORTANTE — ESTABILIDADE DE NAVEGAÇÃO
|
||||
@@ -57,11 +58,44 @@ export function setOnSignedOut(cb) {
|
||||
onSignedOutCallback = typeof cb === 'function' ? cb : null;
|
||||
}
|
||||
|
||||
// V#2: session.js é o único registrante de supabase.auth.onAuthStateChange.
|
||||
// Outros módulos (guards.js, etc.) se inscrevem aqui via onSessionEvent
|
||||
// em vez de registrar listeners próprios.
|
||||
const eventHandlers = {
|
||||
SIGNED_IN: [],
|
||||
SIGNED_OUT: [],
|
||||
TOKEN_REFRESHED: [],
|
||||
USER_UPDATED: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Inscreve handler para um evento de auth processado por listenAuthChanges.
|
||||
* Retorna função pra desregistrar.
|
||||
*
|
||||
* @param {'SIGNED_IN'|'SIGNED_OUT'|'TOKEN_REFRESHED'|'USER_UPDATED'} event
|
||||
* @param {(session: object|null) => void} handler
|
||||
*/
|
||||
export function onSessionEvent(event, handler) {
|
||||
if (!eventHandlers[event] || typeof handler !== 'function') return () => {};
|
||||
eventHandlers[event].push(handler);
|
||||
return () => {
|
||||
eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(event, sess) {
|
||||
const handlers = eventHandlers[event];
|
||||
if (!handlers || !handlers.length) return;
|
||||
for (const h of handlers) {
|
||||
try { h(sess); } catch (e) { logError('session', `${event} handler failed`, e); }
|
||||
}
|
||||
}
|
||||
|
||||
// evita init concorrente
|
||||
let initPromise = null;
|
||||
|
||||
async function fetchRole(userId) {
|
||||
const { data, error } = await supabase.from('profiles').select('role').eq('id', userId).single();
|
||||
const { data, error } = await supabase.from('profiles').select('role').eq('id', userId).maybeSingle();
|
||||
|
||||
if (error) return null;
|
||||
return data?.role || null;
|
||||
@@ -129,7 +163,7 @@ export async function initSession({ initial = false } = {}) {
|
||||
sessionIsSaasAdmin.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[initSession] getSession falhou (tratando como sem sessão):', e);
|
||||
logError('session', 'initSession.getSession falhou (tratando como sem sessão)', e);
|
||||
// não deixa estourar pro router guard
|
||||
sessionUser.value = null;
|
||||
sessionRole.value = null;
|
||||
@@ -173,7 +207,7 @@ export function listenAuthChanges() {
|
||||
if (authSubscription) return;
|
||||
|
||||
const { data } = supabase.auth.onAuthStateChange(async (event, sess) => {
|
||||
console.log('[AUTH EVENT]', event);
|
||||
logAuth('event', { event });
|
||||
|
||||
// ✅ SIGNED_OUT: zera e chama callback
|
||||
if (event === 'SIGNED_OUT') {
|
||||
@@ -183,6 +217,7 @@ export function listenAuthChanges() {
|
||||
sessionRefreshing.value = false;
|
||||
sessionReady.value = true;
|
||||
if (onSignedOutCallback) onSignedOutCallback();
|
||||
dispatch('SIGNED_OUT', sess);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,6 +225,7 @@ export function listenAuthChanges() {
|
||||
if (event === 'SIGNED_IN') {
|
||||
const uid = sess?.user?.id || null;
|
||||
if (uid && sessionReady.value && sessionUser.value?.id === uid && sessionRole.value) {
|
||||
dispatch('SIGNED_IN', sess);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -204,10 +240,11 @@ export function listenAuthChanges() {
|
||||
await hydrateFromSession(sess);
|
||||
sessionReady.value = true;
|
||||
} catch (e) {
|
||||
console.warn('[auth hydrate error]', e);
|
||||
logError('session', 'auth hydrate error', e);
|
||||
} finally {
|
||||
sessionRefreshing.value = false;
|
||||
}
|
||||
dispatch(event === 'TOKEN_REFRESHED' ? 'TOKEN_REFRESHED' : 'SIGNED_IN', sess);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +252,7 @@ export function listenAuthChanges() {
|
||||
try {
|
||||
await refreshSession();
|
||||
} catch (e) {
|
||||
console.error('[refreshSession error]', e);
|
||||
logError('session', 'refreshSession error', e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — MathCaptchaChallenge (A#20 rev2)
|
||||
|--------------------------------------------------------------------------
|
||||
| Componente de captcha matemático invocado SOB DEMANDA quando a edge
|
||||
| function retorna 403 captcha-required ou na primeira tentativa se o IP
|
||||
| já está marcado como suspeito.
|
||||
|
|
||||
| Uso:
|
||||
| <MathCaptchaChallenge
|
||||
| v-model:id="captchaId"
|
||||
| v-model:answer="captchaAnswer"
|
||||
| :function-url="..."
|
||||
| />
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Button from 'primevue/button';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, default: '' },
|
||||
answer: { type: [Number, null], default: null },
|
||||
autoLoad: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:id', 'update:answer']);
|
||||
|
||||
const challenge = ref({ id: '', question: '' });
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const localAnswer = ref(props.answer);
|
||||
|
||||
async function loadChallenge() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data, error: fnErr } = await supabase.functions.invoke('submit-patient-intake/captcha-challenge', {
|
||||
method: 'POST',
|
||||
body: {}
|
||||
});
|
||||
if (fnErr) throw fnErr;
|
||||
const ch = data?.challenge || data;
|
||||
challenge.value = { id: ch?.id || '', question: ch?.question || '' };
|
||||
emit('update:id', challenge.value.id);
|
||||
localAnswer.value = null;
|
||||
emit('update:answer', null);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Não foi possível carregar a verificação.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAnswerChange(v) {
|
||||
localAnswer.value = v;
|
||||
emit('update:answer', v);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoLoad) loadChallenge();
|
||||
});
|
||||
|
||||
defineExpose({ loadChallenge });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-amber-400/30 bg-amber-400/5 p-4">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="text-sm font-semibold text-slate-100">
|
||||
<i class="pi pi-shield mr-2 text-amber-300" />
|
||||
Verificação rápida
|
||||
</div>
|
||||
<Button icon="pi pi-refresh" text size="small" :loading="loading" @click="loadChallenge" v-tooltip.top="'Outra pergunta'" />
|
||||
</div>
|
||||
|
||||
<p v-if="!challenge.question && !loading" class="text-xs text-slate-300">Carregando…</p>
|
||||
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
|
||||
|
||||
<div v-if="challenge.question" class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span class="text-base text-slate-100 font-medium">{{ challenge.question }}</span>
|
||||
<InputNumber
|
||||
:modelValue="localAnswer"
|
||||
@update:modelValue="onAnswerChange"
|
||||
placeholder="?"
|
||||
class="!w-32"
|
||||
:useGrouping="false"
|
||||
inputClass="text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-[11px] text-slate-400">Confirma que você é humano. Sem cookies, sem rastreio externo.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -222,8 +222,8 @@ export function useAgendaFinanceiro() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── remarcar → atualizar due_date da cobrança existente ────────────
|
||||
if (novoStatus === 'remarcar' && evento.billed) {
|
||||
// ── remarcado → atualizar due_date da cobrança existente ────────────
|
||||
if (novoStatus === 'remarcado' && evento.billed) {
|
||||
// due_date mantém a data da sessão original por enquanto
|
||||
// (a nova data virá quando a sessão for reagendada)
|
||||
return { ok: true };
|
||||
|
||||
@@ -295,7 +295,7 @@ watch(
|
||||
async (newVal, oldVal) => {
|
||||
if (_skipStatusWatch.value) return;
|
||||
if (!isEdit.value || !form.value?.id) return;
|
||||
if (newVal !== 'cancelado' && newVal !== 'remarcar') return;
|
||||
if (newVal !== 'cancelado' && newVal !== 'remarcado') return;
|
||||
|
||||
_prevStatus.value = oldVal;
|
||||
|
||||
@@ -1073,7 +1073,7 @@ const statusOptions = [
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' },
|
||||
{ label: 'Remarcar', value: 'remarcar' }
|
||||
{ label: 'Remarcar', value: 'remarcado' }
|
||||
];
|
||||
|
||||
const serieCountByStatus = computed(() => {
|
||||
@@ -1122,7 +1122,7 @@ const statusOptionsFiltered = computed(() => [
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' },
|
||||
{ label: 'Remarcar', value: 'remarcar', disabled: isInativoFutureEdit.value }
|
||||
{ label: 'Remarcar', value: 'remarcado', disabled: isInativoFutureEdit.value }
|
||||
]);
|
||||
function fmtWeekdayShort(iso) {
|
||||
return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3);
|
||||
@@ -1661,7 +1661,7 @@ const googleCalendarUrl = computed(() => {
|
||||
});
|
||||
|
||||
function labelStatusSessao(v) {
|
||||
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado', remarcar: 'Remarcar' };
|
||||
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado', remarcado: 'Remarcado' };
|
||||
return map[v] || '—';
|
||||
}
|
||||
function statusSeverity(v) {
|
||||
@@ -1669,11 +1669,11 @@ function statusSeverity(v) {
|
||||
if (v === 'realizado') return 'success';
|
||||
if (v === 'faltou') return 'warn';
|
||||
if (v === 'cancelado') return 'danger';
|
||||
if (v === 'remarcar') return 'secondary'; // cor real via classe CSS
|
||||
if (v === 'remarcado') return 'secondary'; // cor real via classe CSS
|
||||
return 'secondary';
|
||||
}
|
||||
function statusExtraClass(v) {
|
||||
return v === 'remarcar' ? 'tag-remarcar' : '';
|
||||
return v === 'remarcado' ? 'tag-remarcado' : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1949,7 +1949,7 @@ function statusExtraClass(v) {
|
||||
<span v-if="serieCountByStatus.realizado"> · {{ serieCountByStatus.realizado }} realizadas</span>
|
||||
<span v-if="serieCountByStatus.faltou"> · {{ serieCountByStatus.faltou }} faltaram</span>
|
||||
<span v-if="serieCountByStatus.cancelado"> · {{ serieCountByStatus.cancelado }} canceladas</span>
|
||||
<span v-if="serieCountByStatus.remarcar"> · {{ serieCountByStatus.remarcar }} para remarcar</span>
|
||||
<span v-if="serieCountByStatus.remarcado"> · {{ serieCountByStatus.remarcado }} para remarcar</span>
|
||||
</div>
|
||||
<span v-if="serieLoading" class="ml-auto text-xs opacity-50">Carregando…</span>
|
||||
</div>
|
||||
@@ -2482,8 +2482,8 @@ function statusExtraClass(v) {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── tag: remarcar (roxo — sem severity nativo no PrimeVue) ─ */
|
||||
:deep(.tag-remarcar) {
|
||||
/* ── tag: remarcado (roxo — sem severity nativo no PrimeVue) ─ */
|
||||
:deep(.tag-remarcado) {
|
||||
background: #a855f7 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
@@ -3302,7 +3302,7 @@ function statusExtraClass(v) {
|
||||
.serie-pill--cancelado {
|
||||
border-left-color: var(--surface-border);
|
||||
}
|
||||
.serie-pill--remarcar {
|
||||
.serie-pill--remarcado {
|
||||
border-left-color: var(--orange-400, #fb923c);
|
||||
}
|
||||
.serie-pill__date {
|
||||
|
||||
@@ -207,7 +207,7 @@ async function confirmar() {
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert(rows);
|
||||
if (error) throw error;
|
||||
|
||||
// Marcar sessões existentes como "remarcar"
|
||||
// Marcar sessões existentes como "remarcado"
|
||||
await marcarSessoesParaRemarcar(rows);
|
||||
|
||||
toast.add({
|
||||
@@ -226,10 +226,10 @@ async function confirmar() {
|
||||
}
|
||||
|
||||
async function marcarSessoesParaRemarcar(bloqueios) {
|
||||
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcar'
|
||||
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcado'
|
||||
for (const b of bloqueios) {
|
||||
try {
|
||||
let query = supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`);
|
||||
let query = supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`);
|
||||
|
||||
if (b.hora_inicio && b.hora_fim) {
|
||||
// filtra pela hora aproximada — comparação UTC simplificada
|
||||
|
||||
@@ -156,8 +156,8 @@ async function confirmarBloqueio(feriado) {
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert([row]);
|
||||
if (error) throw error;
|
||||
|
||||
// Marcar sessões existentes no dia como 'remarcar'
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
// Marcar sessões existentes no dia como 'remarcado'
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data]);
|
||||
toast.add({
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* useAgendaEvents.spec.js — T#9
|
||||
*
|
||||
* Wrapper fino do agendaRepository. Cobertura focada nos contratos:
|
||||
* - rows/loading/error reativos
|
||||
* - delega I/O ao repository (nada de fetch direto)
|
||||
* - sem ownerId, loadMyRange é no-op (proteção tenant scoping)
|
||||
* - error é capturado em loadMyRange e zera rows; create/update/remove propagam
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const listMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
const removeMock = vi.fn();
|
||||
|
||||
vi.mock('@/features/agenda/services/agendaRepository', () => ({
|
||||
listMyAgendaEvents: (...a) => listMock(...a),
|
||||
createAgendaEvento: (...a) => createMock(...a),
|
||||
updateAgendaEvento: (...a) => updateMock(...a),
|
||||
deleteAgendaEvento: (...a) => removeMock(...a)
|
||||
}));
|
||||
|
||||
const { useAgendaEvents } = await import('../useAgendaEvents.js');
|
||||
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
createMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
removeMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useAgendaEvents — estado inicial', () => {
|
||||
it('rows vazio, loading false, error null', () => {
|
||||
const { rows, loading, error } = useAgendaEvents();
|
||||
expect(rows.value).toEqual([]);
|
||||
expect(loading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMyRange', () => {
|
||||
it('sem ownerId, não chama o repository (no-op de segurança)', async () => {
|
||||
const { rows, loadMyRange } = useAgendaEvents();
|
||||
await loadMyRange('2026-01-01', '2026-01-31', null);
|
||||
await loadMyRange('2026-01-01', '2026-01-31', '');
|
||||
await loadMyRange('2026-01-01', '2026-01-31', undefined);
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
expect(rows.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('chama listMyAgendaEvents com payload correto e popula rows', async () => {
|
||||
listMock.mockResolvedValue([{ id: 'a' }, { id: 'b' }]);
|
||||
const { rows, loading, loadMyRange } = useAgendaEvents();
|
||||
|
||||
const promise = loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
// loading vira true durante a execução
|
||||
expect(loading.value).toBe(true);
|
||||
await promise;
|
||||
|
||||
expect(listMock).toHaveBeenCalledWith({
|
||||
startISO: '2026-01-01',
|
||||
endISO: '2026-01-31',
|
||||
ownerId: 'u-1'
|
||||
});
|
||||
expect(rows.value).toEqual([{ id: 'a' }, { id: 'b' }]);
|
||||
expect(loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('captura erro, zera rows e seta error.message', async () => {
|
||||
listMock.mockRejectedValue(new Error('fetch fail'));
|
||||
const { rows, loading, error, loadMyRange } = useAgendaEvents();
|
||||
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
|
||||
expect(rows.value).toEqual([]);
|
||||
expect(loading.value).toBe(false);
|
||||
expect(error.value).toBe('fetch fail');
|
||||
});
|
||||
|
||||
it('reseta error em call subsequente bem-sucedida', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('first fail')).mockResolvedValueOnce([{ id: 'x' }]);
|
||||
const { error, loadMyRange } = useAgendaEvents();
|
||||
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
expect(error.value).toBe('first fail');
|
||||
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('error sem message vira fallback string', async () => {
|
||||
listMock.mockRejectedValue({}); // sem .message
|
||||
const { error, loadMyRange } = useAgendaEvents();
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
expect(error.value).toBe('Erro ao carregar eventos');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create / update / remove — delegação pura', () => {
|
||||
it('create encaminha payload e retorna o resultado do repository', async () => {
|
||||
createMock.mockResolvedValue({ id: 'new', titulo: 'X' });
|
||||
const { create } = useAgendaEvents();
|
||||
const result = await create({ titulo: 'X' });
|
||||
expect(createMock).toHaveBeenCalledWith({ titulo: 'X' });
|
||||
expect(result).toEqual({ id: 'new', titulo: 'X' });
|
||||
});
|
||||
|
||||
it('update encaminha id+patch e retorna o resultado', async () => {
|
||||
updateMock.mockResolvedValue({ id: '42', titulo: 'updated' });
|
||||
const { update } = useAgendaEvents();
|
||||
const result = await update('42', { titulo: 'updated' });
|
||||
expect(updateMock).toHaveBeenCalledWith('42', { titulo: 'updated' });
|
||||
expect(result).toEqual({ id: '42', titulo: 'updated' });
|
||||
});
|
||||
|
||||
it('remove encaminha id (sem retorno)', async () => {
|
||||
removeMock.mockResolvedValue(undefined);
|
||||
const { remove } = useAgendaEvents();
|
||||
const result = await remove('99');
|
||||
expect(removeMock).toHaveBeenCalledWith('99');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('create propaga erro (não engole)', async () => {
|
||||
createMock.mockRejectedValue(new Error('insert blocked by RLS'));
|
||||
const { create } = useAgendaEvents();
|
||||
await expect(create({ titulo: 'X' })).rejects.toThrow(/RLS/);
|
||||
});
|
||||
|
||||
it('update propaga erro', async () => {
|
||||
updateMock.mockRejectedValue(new Error('not found'));
|
||||
const { update } = useAgendaEvents();
|
||||
await expect(update('1', { x: 1 })).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('remove propaga erro', async () => {
|
||||
removeMock.mockRejectedValue(new Error('cascade fail'));
|
||||
const { remove } = useAgendaEvents();
|
||||
await expect(remove('1')).rejects.toThrow(/cascade/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isolamento entre instâncias', () => {
|
||||
it('cada useAgendaEvents() retorna refs independentes', async () => {
|
||||
listMock.mockResolvedValue([{ id: 'a' }]);
|
||||
const a = useAgendaEvents();
|
||||
const b = useAgendaEvents();
|
||||
|
||||
await a.loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
|
||||
expect(a.rows.value).toEqual([{ id: 'a' }]);
|
||||
expect(b.rows.value).toEqual([]); // b não foi tocado
|
||||
});
|
||||
});
|
||||
@@ -16,75 +16,33 @@
|
||||
*/
|
||||
/**
|
||||
* useAgendaEvents.js
|
||||
* src/features/agenda/composables/useAgendaEvents.js
|
||||
*
|
||||
* Gerencia apenas eventos reais (agenda_eventos).
|
||||
* Sessões com recurrence_id são sessões reais de uma série.
|
||||
* Wrapper fino sobre agendaRepository — agrega estado reativo (rows/loading/error)
|
||||
* e delega toda a lógica de I/O ao repository. Mesmo padrão de useAgendaClinicEvents.
|
||||
*
|
||||
* Só gerencia eventos reais (agenda_eventos). Ocorrências virtuais de séries são
|
||||
* responsabilidade do useRecurrence.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||
|
||||
function assertTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar na agenda.');
|
||||
}
|
||||
}
|
||||
|
||||
async function getUid() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
const BASE_SELECT = `
|
||||
id, owner_id, patient_id, tipo, status,
|
||||
titulo, titulo_custom, observacoes, inicio_em, fim_em,
|
||||
terapeuta_id, tenant_id, visibility_scope,
|
||||
determined_commitment_id, link_online, extra_fields, modalidade,
|
||||
recurrence_id, recurrence_date,
|
||||
mirror_of_event_id, price,
|
||||
insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id,
|
||||
patients!agenda_eventos_patient_id_fkey (
|
||||
id, nome_completo, avatar_url, status
|
||||
),
|
||||
determined_commitments!agenda_eventos_determined_commitment_fk (
|
||||
id, bg_color, text_color
|
||||
)
|
||||
`.trim();
|
||||
import {
|
||||
listMyAgendaEvents,
|
||||
createAgendaEvento,
|
||||
updateAgendaEvento,
|
||||
deleteAgendaEvento
|
||||
} from '@/features/agenda/services/agendaRepository';
|
||||
|
||||
export function useAgendaEvents() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function loadMyRange(start, end, ownerId) {
|
||||
async function loadMyRange(startISO, endISO, ownerId) {
|
||||
if (!ownerId) return;
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select(BASE_SELECT)
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', ownerId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', start)
|
||||
.lte('inicio_em', end)
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
if (err) throw err;
|
||||
rows.value = (data || []).map(flattenRow);
|
||||
rows.value = await listMyAgendaEvents({ startISO, endISO, ownerId });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar eventos';
|
||||
rows.value = [];
|
||||
@@ -93,89 +51,17 @@ export function useAgendaEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um evento injetando tenant_id e owner_id automaticamente.
|
||||
* owner_id é sempre o usuário autenticado — nunca vem do payload externo.
|
||||
* tenant_id vem do tenantStore ativo — nunca do payload externo.
|
||||
*/
|
||||
async function create(payload) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const uid = await getUid();
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...rest } = payload;
|
||||
const safePayload = {
|
||||
...rest,
|
||||
tenant_id: tenantId,
|
||||
owner_id: uid
|
||||
};
|
||||
|
||||
const { data, error: err } = await supabase.from('agenda_eventos').insert([safePayload]).select(BASE_SELECT).single();
|
||||
if (err) throw err;
|
||||
return flattenRow(data);
|
||||
return createAgendaEvento(payload);
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...safePatch } = patch;
|
||||
|
||||
const { data, error: err } = await supabase.from('agenda_eventos').update(safePatch).eq('id', id).eq('tenant_id', tenantId).select(BASE_SELECT).single();
|
||||
if (err) throw err;
|
||||
return flattenRow(data);
|
||||
return updateAgendaEvento(id, patch);
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const { error: err } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
await deleteAgendaEvento(id);
|
||||
}
|
||||
|
||||
async function removeSeriesFrom(recurrenceId, fromDateISO) {
|
||||
if (!recurrenceId) throw new Error('recurrenceId inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const { error: err } = await supabase.from('agenda_eventos').delete().eq('recurrence_id', recurrenceId).eq('tenant_id', tenantId).gte('recurrence_date', fromDateISO);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
async function removeAllSeries(recurrenceId) {
|
||||
if (!recurrenceId) throw new Error('recurrenceId inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const { error: err } = await supabase.from('agenda_eventos').delete().eq('recurrence_id', recurrenceId).eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadMyRange, create, update, remove, removeSeriesFrom, removeAllSeries };
|
||||
}
|
||||
|
||||
function flattenRow(r) {
|
||||
if (!r) return r;
|
||||
const patient = r.patients || null;
|
||||
const out = { ...r };
|
||||
delete out.patients;
|
||||
out.paciente_nome = patient?.nome_completo || out.paciente_nome || '';
|
||||
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || '';
|
||||
out.paciente_status = patient?.status || out.paciente_status || '';
|
||||
return out;
|
||||
return { rows, loading, error, loadMyRange, create, update, remove };
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId } from '@/features/agenda/services/_tenantGuards';
|
||||
import { logRecurrence, logError, logPerf } from '@/support/supportLogger';
|
||||
|
||||
// ─── helpers de data ────────────────────────────────────────────────────────
|
||||
@@ -197,6 +199,11 @@ export function generateDates(rule, rangeStart, rangeEnd) {
|
||||
|
||||
// ─── expansão principal ──────────────────────────────────────────────────────
|
||||
|
||||
// Cap defensivo: a agenda real sempre passa ranges mensais/semanais (≤42d).
|
||||
// Range muito grande com muitas regras = milhares de ocorrências no browser.
|
||||
// Não bloqueamos (relatórios legítimos podem precisar), só avisamos.
|
||||
const MAX_RANGE_DAYS = 730; // 2 anos
|
||||
|
||||
/**
|
||||
* Expande regras em ocorrências, aplica exceções.
|
||||
*
|
||||
@@ -207,6 +214,15 @@ export function generateDates(rule, rangeStart, rangeEnd) {
|
||||
* @returns {Array} occurrences — objetos com shape compatível com FullCalendar
|
||||
*/
|
||||
export function expandRules(rules, exceptions, rangeStart, rangeEnd) {
|
||||
const rangeDays = Math.round((rangeEnd.getTime() - rangeStart.getTime()) / 86_400_000);
|
||||
if (rangeDays > MAX_RANGE_DAYS) {
|
||||
logError('useRecurrence', 'expandRules: range grande pode degradar UI', {
|
||||
rangeDays,
|
||||
maxRecommended: MAX_RANGE_DAYS,
|
||||
ruleCount: (rules || []).length
|
||||
});
|
||||
}
|
||||
|
||||
// índice de exceções por regra+data
|
||||
const exMap = new Map();
|
||||
for (const ex of exceptions || []) {
|
||||
@@ -399,6 +415,13 @@ export function useRecurrence() {
|
||||
const exceptions = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
function currentTenantId() {
|
||||
const tid = tenantStore.activeTenantId;
|
||||
assertTenantId(tid);
|
||||
return tid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega regras ativas para um owner no range dado.
|
||||
@@ -493,6 +516,7 @@ export function useRecurrence() {
|
||||
return true;
|
||||
});
|
||||
} catch (e) {
|
||||
logError('useRecurrence', 'loadExceptions ERRO', e);
|
||||
error.value = e?.message || 'Erro ao carregar exceções';
|
||||
exceptions.value = [];
|
||||
}
|
||||
@@ -546,13 +570,16 @@ export function useRecurrence() {
|
||||
// ── CRUD de regras ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cria uma nova regra de recorrência
|
||||
* Cria uma nova regra de recorrência.
|
||||
* tenant_id é injetado do tenantStore se não vier no payload (defesa em profundidade).
|
||||
* @param {Object} rule - campos da tabela recurrence_rules
|
||||
* @returns {Object} regra criada
|
||||
*/
|
||||
async function createRule(rule) {
|
||||
const tenantId = currentTenantId();
|
||||
logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type });
|
||||
const { data, error: err } = await supabase.from('recurrence_rules').insert([rule]).select('*').single();
|
||||
const safeRule = { ...rule, tenant_id: rule?.tenant_id || tenantId };
|
||||
const { data, error: err } = await supabase.from('recurrence_rules').insert([safeRule]).select('*').single();
|
||||
if (err) {
|
||||
logError('useRecurrence', 'createRule ERRO', err);
|
||||
throw err;
|
||||
@@ -562,13 +589,16 @@ export function useRecurrence() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza a regra toda (editar todos)
|
||||
* Atualiza a regra toda (editar todos).
|
||||
* Filtro adicional por tenant_id — defesa em profundidade (RLS cobre, mas reforçamos).
|
||||
*/
|
||||
async function updateRule(id, patch) {
|
||||
const tenantId = currentTenantId();
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.update({ ...patch, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
@@ -576,10 +606,15 @@ export function useRecurrence() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela a série inteira
|
||||
* Cancela a série inteira (filtro por tenant_id — defesa em profundidade).
|
||||
*/
|
||||
async function cancelRule(id) {
|
||||
const { error: err } = await supabase.from('recurrence_rules').update({ status: 'cancelado', updated_at: new Date().toISOString() }).eq('id', id);
|
||||
const tenantId = currentTenantId();
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
@@ -610,19 +645,33 @@ export function useRecurrence() {
|
||||
// ── CRUD de exceções ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cria ou atualiza uma exceção para uma ocorrência específica
|
||||
* Cria ou atualiza uma exceção para uma ocorrência específica.
|
||||
* tenant_id é injetado do tenantStore se não vier no payload.
|
||||
*/
|
||||
async function upsertException(ex) {
|
||||
const { data, error: err } = await supabase.from('recurrence_exceptions').upsert([ex], { onConflict: 'recurrence_id,original_date' }).select('*').single();
|
||||
const tenantId = currentTenantId();
|
||||
const safeEx = { ...ex, tenant_id: ex?.tenant_id || tenantId };
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
.upsert([safeEx], { onConflict: 'recurrence_id,original_date' })
|
||||
.select('*')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove uma exceção (restaura a ocorrência ao normal)
|
||||
* Remove uma exceção (restaura a ocorrência ao normal).
|
||||
* Filtro por tenant_id — defesa em profundidade.
|
||||
*/
|
||||
async function deleteException(recurrenceId, originalDate) {
|
||||
const { error: err } = await supabase.from('recurrence_exceptions').delete().eq('recurrence_id', recurrenceId).eq('original_date', originalDate);
|
||||
const tenantId = currentTenantId();
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
.delete()
|
||||
.eq('recurrence_id', recurrenceId)
|
||||
.eq('original_date', originalDate)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -1968,7 +1968,7 @@ async function bloquearFeriadoDoAlerta(feriado) {
|
||||
}
|
||||
]);
|
||||
if (error) throw error;
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
|
||||
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
|
||||
|
||||
@@ -1330,7 +1330,7 @@ async function bloquearFeriadoDoAlerta(feriado) {
|
||||
}
|
||||
]);
|
||||
if (error) throw error;
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
|
||||
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
|
||||
|
||||
@@ -81,12 +81,12 @@ describe('mapAgendaEventosToCalendarEvents', () => {
|
||||
|
||||
it('aplica cor de fundo para status faltou', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })]);
|
||||
expect(ev.backgroundColor).toBe('#ef4444');
|
||||
expect(ev.backgroundColor).toBe('#f97316');
|
||||
});
|
||||
|
||||
it('aplica cor de fundo para status cancelado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })]);
|
||||
expect(ev.backgroundColor).toBe('#f97316');
|
||||
expect(ev.backgroundColor).toBe('#ef4444');
|
||||
});
|
||||
|
||||
it('aplica cor de fundo para status remarcado', () => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/agenda/services/_tenantGuards.js
|
||||
|
|
||||
| Guards compartilhados entre composables e repositories do feature agenda.
|
||||
| Antes: assertTenantId e getUid duplicados em 3+ arquivos.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export function assertTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar na agenda.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUid() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
export function assertIsoRange(startISO, endISO) {
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
|
||||
}
|
||||
|
||||
export function sanitizeOwnerIds(ownerIds) {
|
||||
return (ownerIds || []).filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/agenda/services/agenda.service.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
@@ -15,20 +15,12 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
function assertValidTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de carregar a agenda.');
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidIsoRange(startISO, endISO) {
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
|
||||
}
|
||||
|
||||
function sanitizeOwnerIds(ownerIds) {
|
||||
return (ownerIds || []).filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
|
||||
}
|
||||
import {
|
||||
assertTenantId as assertValidTenantId,
|
||||
assertIsoRange as assertValidIsoRange,
|
||||
sanitizeOwnerIds
|
||||
} from './_tenantGuards';
|
||||
import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
|
||||
|
||||
/**
|
||||
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
|
||||
@@ -44,7 +36,7 @@ export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url, status), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
@@ -52,7 +44,7 @@ export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO }
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
return (data || []).map(flattenAgendaRow);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,10 +83,14 @@ export async function createClinicAgendaEvento(payload, { tenantId } = {}) {
|
||||
tenant_id: tenantId
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('agenda_eventos').insert(insertPayload).select('*').single();
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert(insertPayload)
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
return flattenAgendaRow(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,10 +103,16 @@ export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
if (!patch) throw new Error('Patch vazio.');
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('agenda_eventos').update(patch).eq('id', id).eq('tenant_id', tenantId).select('*').single();
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
return flattenAgendaRow(data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -243,7 +243,7 @@ function _statusBgColor(status) {
|
||||
realizado: '#22c55e', // verde
|
||||
faltou: '#f97316', // laranja
|
||||
cancelado: '#ef4444', // vermelho
|
||||
remarcar: '#a855f7', // roxo
|
||||
remarcado: '#a855f7', // roxo
|
||||
bloqueado: '#6b7280' // cinza
|
||||
};
|
||||
return map[status] ?? null;
|
||||
@@ -253,7 +253,7 @@ function _statusIcon(status, isOccurrence, hasSerie) {
|
||||
if (status === 'realizado') return '✓ ';
|
||||
if (status === 'faltou') return '✗ ';
|
||||
if (status === 'cancelado') return '∅ ';
|
||||
if (status === 'remarcar') return '↺ ';
|
||||
if (status === 'remarcado') return '↺ ';
|
||||
if (status === 'bloqueado') return '⊘ ';
|
||||
if (hasSerie || isOccurrence) return '↻ ';
|
||||
return '';
|
||||
|
||||
@@ -16,206 +16,140 @@
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId as assertValidTenantId, assertIsoRange, getUid } from './_tenantGuards';
|
||||
import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
|
||||
|
||||
function assertValidTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de carregar a agenda.');
|
||||
}
|
||||
}
|
||||
// ─── Configurações & jornada ────────────────────────────────────────────────
|
||||
|
||||
async function getUid() {
|
||||
const { data: userRes, error: userErr } = await supabase.auth.getUser();
|
||||
if (userErr) throw userErr;
|
||||
|
||||
const uid = userRes?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurações da agenda (por owner)
|
||||
* Se você decidir que configurações são por tenant também, adicionamos tenant_id aqui.
|
||||
*/
|
||||
export async function getMyAgendaSettings() {
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase.from('agenda_configuracoes').select('*').eq('owner_id', uid).order('created_at', { ascending: false }).limit(1).maybeSingle();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_configuracoes')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regras semanais de jornada (agenda_regras_semanais):
|
||||
* retorna os dias ativos com hora_inicio/hora_fim por dia.
|
||||
*/
|
||||
export async function getMyWorkSchedule() {
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase.from('agenda_regras_semanais').select('dia_semana, hora_inicio, hora_fim, ativo').eq('owner_id', uid).eq('ativo', true).order('dia_semana');
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista agenda do terapeuta (somente do owner logado) dentro do tenant ativo.
|
||||
* Isso impede misturar eventos caso o terapeuta atue em múltiplas clínicas.
|
||||
*/
|
||||
export async function listMyAgendaEvents({ startISO, endISO, tenantId: tenantIdArg } = {}) {
|
||||
const uid = await getUid();
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*, patients(id, nome_completo, avatar_url), determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.from('agenda_regras_semanais')
|
||||
.select('dia_semana, hora_inicio, hora_fim, ativo')
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
|
||||
* IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant.
|
||||
*/
|
||||
export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO }) {
|
||||
assertValidTenantId(tenantId);
|
||||
if (!ownerIds?.length) return [];
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
|
||||
|
||||
// Sanitiza ownerIds
|
||||
const safeOwnerIds = ownerIds.filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
|
||||
|
||||
if (!safeOwnerIds.length) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
.eq('ativo', true)
|
||||
.order('dia_semana');
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function listTenantStaff(tenantId) {
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('v_tenant_staff').select('*').eq('tenant_id', tenantId);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ─── Agenda do terapeuta ────────────────────────────────────────────────────
|
||||
|
||||
function resolveTenantId(tenantIdArg) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertValidTenantId(tenantId);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista eventos do terapeuta logado (owner_id = uid) dentro do tenant ativo.
|
||||
* Retorna rows flattened (paciente_nome/paciente_avatar/paciente_status no topo).
|
||||
*
|
||||
* Parâmetros:
|
||||
* - startISO/endISO: half-open range (lt no final — consistente com clinic)
|
||||
* - ownerId: opcional — default é o uid do usuário logado
|
||||
* - tenantId: opcional — default é tenantStore.activeTenantId
|
||||
* - excludeMirror: se true, filtra mirror_of_event_id IS NULL (agenda do terapeuta
|
||||
* não deve mostrar eventos espelho de outra clínica)
|
||||
*/
|
||||
export async function listMyAgendaEvents({ startISO, endISO, ownerId, tenantId, excludeMirror = true } = {}) {
|
||||
assertIsoRange(startISO, endISO);
|
||||
const uid = ownerId || (await getUid());
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
let q = supabase
|
||||
.from('agenda_eventos')
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
if (excludeMirror) q = q.is('mirror_of_event_id', null);
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return (data || []).map(flattenAgendaRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Criação segura:
|
||||
* - injeta tenant_id do tenantStore
|
||||
* - injeta owner_id do usuário logado (ignora owner_id vindo de fora)
|
||||
*
|
||||
* Observação:
|
||||
* - Para admin/secretária criar para outros owners, o ideal é ter uma função separada
|
||||
* (ex.: createClinicAgendaEvento) que permita owner_id explicitamente.
|
||||
* Por enquanto, deixo esta função como "safe default" para terapeuta.
|
||||
* - dropa paciente_id (campo legado) se vier no payload
|
||||
*/
|
||||
export async function createAgendaEvento(payload) {
|
||||
const uid = await getUid();
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
if (!payload) throw new Error('Payload vazio.');
|
||||
const uid = await getUid();
|
||||
const tid = resolveTenantId();
|
||||
|
||||
const insertPayload = {
|
||||
...payload,
|
||||
tenant_id: tenantId,
|
||||
owner_id: uid
|
||||
};
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...rest } = payload;
|
||||
const insertPayload = { ...rest, tenant_id: tid, owner_id: uid };
|
||||
|
||||
const { data, error } = await supabase.from('agenda_eventos').insert(insertPayload).select('*').single();
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert([insertPayload])
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
return flattenAgendaRow(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualização segura:
|
||||
* - filtra por id + tenant_id (evita update cruzado por acidente)
|
||||
* RLS deve reforçar isso no banco.
|
||||
* Atualização segura: filtra por id + tenant_id (RLS reforça no banco).
|
||||
*/
|
||||
export async function updateAgendaEvento(id, patch, { tenantId: tenantIdArg } = {}) {
|
||||
export async function updateAgendaEvento(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
if (!patch) throw new Error('Patch vazio.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertValidTenantId(tenantId);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...safePatch } = patch;
|
||||
|
||||
const { data, error } = await supabase.from('agenda_eventos').update(patch).eq('id', id).eq('tenant_id', tenantId).select('*').single();
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(safePatch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tid)
|
||||
.select(AGENDA_EVENT_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
return flattenAgendaRow(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete seguro:
|
||||
* - filtra por id + tenant_id
|
||||
* Delete seguro: filtra por id + tenant_id.
|
||||
*/
|
||||
export async function deleteAgendaEvento(id, { tenantId: tenantIdArg } = {}) {
|
||||
export async function deleteAgendaEvento(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId);
|
||||
|
||||
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tid);
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Adicione no mesmo arquivo: src/features/agenda/services/agendaRepository.js
|
||||
|
||||
/**
|
||||
* Criação para a área da clínica (admin/secretária):
|
||||
* - exige tenantId explícito (ou cai no tenantStore)
|
||||
* - permite definir owner_id (terapeuta dono do compromisso)
|
||||
*
|
||||
* Segurança real deve ser garantida por RLS:
|
||||
* - clinic_admin/tenant_admin pode criar para qualquer owner dentro do tenant
|
||||
* - therapist não deve conseguir passar daqui (guard + RLS)
|
||||
*/
|
||||
export async function createClinicAgendaEvento(payload, { tenantId: tenantIdArg } = {}) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertValidTenantId(tenantId);
|
||||
|
||||
if (!payload) throw new Error('Payload vazio.');
|
||||
|
||||
const ownerId = payload.owner_id;
|
||||
if (!ownerId || ownerId === 'null' || ownerId === 'undefined') {
|
||||
throw new Error('owner_id é obrigatório para criação pela clínica.');
|
||||
}
|
||||
|
||||
const insertPayload = {
|
||||
...payload,
|
||||
tenant_id: tenantId
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('agenda_eventos').insert(insertPayload).select('*').single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/agenda/services/agendaSelects.js
|
||||
|
|
||||
| Fonte única do SELECT de agenda_eventos. Antes estava duplicado em 3
|
||||
| lugares (useAgendaEvents, agendaRepository, agendaClinicRepository) com
|
||||
| inconsistências sutis (FKs explícitas em uns, não em outros).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Select canônico de agenda_eventos com joins do paciente e do commitment.
|
||||
*
|
||||
* FKs explícitas (obrigatórias para evitar ambiguidade quando agenda_eventos
|
||||
* tem múltiplas colunas apontando para a mesma tabela):
|
||||
* - agenda_eventos_patient_id_fkey
|
||||
* - agenda_eventos_determined_commitment_fk
|
||||
*/
|
||||
export const AGENDA_EVENT_SELECT = `
|
||||
id, owner_id, patient_id, tipo, status,
|
||||
titulo, titulo_custom, observacoes, inicio_em, fim_em,
|
||||
terapeuta_id, tenant_id, visibility_scope,
|
||||
determined_commitment_id, link_online, extra_fields, modalidade,
|
||||
recurrence_id, recurrence_date,
|
||||
mirror_of_event_id, price,
|
||||
insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id,
|
||||
patients!agenda_eventos_patient_id_fkey (
|
||||
id, nome_completo, avatar_url, status
|
||||
),
|
||||
determined_commitments!agenda_eventos_determined_commitment_fk (
|
||||
id, bg_color, text_color
|
||||
)
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Achata o aninhamento de patients dentro da row.
|
||||
* O resto do app usa tanto `paciente_nome` (flat) quanto `patients?.nome_completo`
|
||||
* (aninhado). Mantemos ambos após o flatten.
|
||||
*/
|
||||
export function flattenAgendaRow(r) {
|
||||
if (!r) return r;
|
||||
const patient = r.patients || null;
|
||||
const out = { ...r };
|
||||
out.paciente_nome = patient?.nome_completo || r.paciente_nome || '';
|
||||
out.paciente_avatar = patient?.avatar_url || r.paciente_avatar || '';
|
||||
out.paciente_status = patient?.status || r.paciente_status || '';
|
||||
return out;
|
||||
}
|
||||
@@ -281,7 +281,9 @@ onMounted(async () => {
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-chart-bar cfg-subheader__icon w-10 h-10 rounded-md shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Receita × Despesa</div>
|
||||
<div class="dash-card__sub">Comparativo dos últimos 6 meses</div>
|
||||
@@ -303,7 +305,9 @@ onMounted(async () => {
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-calendar cfg-subheader__icon w-10 h-10 rounded-md shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-calendar text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Projeção de Caixa</div>
|
||||
<div class="dash-card__sub">Cobranças em aberto — próximos 6 meses</div>
|
||||
@@ -345,7 +349,9 @@ onMounted(async () => {
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-list cfg-subheader__icon w-10 h-10 rounded-md shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-list text-lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Últimos lançamentos</div>
|
||||
<div class="dash-card__sub">Cobranças e receitas recentes</div>
|
||||
|
||||
@@ -397,7 +397,9 @@ onBeforeUnmount(() => {
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-filter cfg-subheader__icon w-10 h-10 rounded-md shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-filter text-lg" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Filtros</div>
|
||||
<div class="dash-card__sub">Refine por status, tipo, paciente ou período</div>
|
||||
@@ -547,7 +549,9 @@ onBeforeUnmount(() => {
|
||||
<section class="hidden md:block dash-card rounded-md shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<!-- Header -->
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-table cfg-subheader__icon w-10 h-10 rounded-md shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-table text-lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Registros</div>
|
||||
<div class="dash-card__sub">Lista completa de cobranças e lançamentos</div>
|
||||
|
||||
@@ -30,8 +30,12 @@ import PatientActionMenu from '@/components/patients/PatientActionMenu.vue';
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
import { getSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js';
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// ── Descontos por paciente ────────────────────────────────────────
|
||||
const discountMap = ref({});
|
||||
|
||||
@@ -74,7 +78,7 @@ async function abrirSessoes(pat) {
|
||||
sessoesLista.value = evts.data || [];
|
||||
recorrencias.value = recs.data || [];
|
||||
} catch (e) {
|
||||
console.error('Erro ao carregar sessões:', e);
|
||||
logError('patients', 'abrirSessoes falhou', e);
|
||||
} finally {
|
||||
sessoesLoading.value = false;
|
||||
}
|
||||
@@ -439,6 +443,13 @@ function withOwnerFilter(q) {
|
||||
return uid.value ? q.eq('owner_id', uid.value) : q;
|
||||
}
|
||||
|
||||
// Defesa em profundidade: filtra por tenant_id do tenantStore em todas as queries.
|
||||
// RLS cobre no backend, mas blindamos no cliente (padrão do projeto).
|
||||
function withTenantFilter(q) {
|
||||
const tid = tenantStore.activeTenantId;
|
||||
return tid ? q.eq('tenant_id', tid) : q;
|
||||
}
|
||||
|
||||
// ── Filtered rows ─────────────────────────────────────────
|
||||
const filteredRows = computed(() => {
|
||||
const s = String(filters.search || '')
|
||||
@@ -520,7 +531,7 @@ async function fetchAll() {
|
||||
|
||||
async function listPatients() {
|
||||
let q = supabase.from('patients').select('id, owner_id, nome_completo, email_principal, telefone, avatar_url, status, last_attended_at, created_at, updated_at').order('created_at', { ascending: false });
|
||||
q = withOwnerFilter(q);
|
||||
q = withTenantFilter(withOwnerFilter(q));
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return (data || []).map((p) => ({
|
||||
@@ -536,6 +547,7 @@ async function listPatients() {
|
||||
|
||||
async function listGroups() {
|
||||
let q = supabase.from('patient_groups').select('id, owner_id, nome, cor, is_system, is_active').eq('is_active', true).order('nome', { ascending: true });
|
||||
q = withTenantFilter(q);
|
||||
if (uid.value) q = q.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
|
||||
else q = q.eq('is_system', true);
|
||||
const { data, error } = await q;
|
||||
@@ -545,6 +557,7 @@ async function listGroups() {
|
||||
|
||||
async function listTags() {
|
||||
let q = supabase.from('patient_tags').select('id, owner_id, nome, cor').order('nome', { ascending: true });
|
||||
q = withTenantFilter(q);
|
||||
if (uid.value) q = q.eq('owner_id', uid.value);
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
@@ -555,45 +568,57 @@ async function hydrateAssociationsSupabase() {
|
||||
const ids = (patients.value || []).map((p) => p.id).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
|
||||
const { data: pg, error: pgErr } = await supabase.from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', ids);
|
||||
if (pgErr) throw pgErr;
|
||||
// V#8 — Fase 1 (paralelo): vínculos grupo/tag + agregado de sessões via RPC
|
||||
// (substitui .limit(1000) arbitrário por get_patient_session_counts agregada)
|
||||
const pgQ = withTenantFilter(supabase.from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', ids));
|
||||
const ptQ = withTenantFilter(supabase.from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', ids));
|
||||
const evtQ = supabase.rpc('get_patient_session_counts', { p_patient_ids: ids });
|
||||
|
||||
const groupIds = Array.from(new Set((pg || []).map((r) => r.patient_group_id).filter(Boolean)));
|
||||
let groupCatalog = [];
|
||||
if (groupIds.length) {
|
||||
let gq = supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').in('id', groupIds).eq('is_active', true);
|
||||
if (uid.value) gq = gq.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
|
||||
else gq = gq.eq('is_system', true);
|
||||
const { data: gcatData, error: gErr } = await gq;
|
||||
if (gErr) throw gErr;
|
||||
groupCatalog = (gcatData || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor }));
|
||||
}
|
||||
const [pgRes, ptRes, evtRes] = await Promise.all([pgQ, ptQ, evtQ]);
|
||||
if (pgRes.error) throw pgRes.error;
|
||||
if (ptRes.error) throw ptRes.error;
|
||||
if (evtRes.error) throw evtRes.error;
|
||||
const pg = pgRes.data || [];
|
||||
const pt = ptRes.data || [];
|
||||
const sessionCounts = evtRes.data || []; // [{patient_id, session_count, last_session_at}]
|
||||
|
||||
// Fase 2 (paralelo): catálogos de grupos e tags — dependem dos ids derivados
|
||||
const groupIds = Array.from(new Set(pg.map((r) => r.patient_group_id).filter(Boolean)));
|
||||
const tagIds = Array.from(new Set(pt.map((r) => r.tag_id).filter(Boolean)));
|
||||
|
||||
const groupCatalogQ = groupIds.length
|
||||
? (() => {
|
||||
let q = withTenantFilter(supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').in('id', groupIds).eq('is_active', true));
|
||||
if (uid.value) q = q.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
|
||||
else q = q.eq('is_system', true);
|
||||
return q;
|
||||
})()
|
||||
: Promise.resolve({ data: [], error: null });
|
||||
|
||||
const tagCatalogQ = tagIds.length
|
||||
? withOwnerFilter(withTenantFilter(supabase.from('patient_tags').select('id, nome, cor').in('id', tagIds)))
|
||||
: Promise.resolve({ data: [], error: null });
|
||||
|
||||
const [gcatRes, tcatRes] = await Promise.all([groupCatalogQ, tagCatalogQ]);
|
||||
if (gcatRes.error) throw gcatRes.error;
|
||||
if (tcatRes.error) throw tcatRes.error;
|
||||
|
||||
const groupCatalog = (gcatRes.data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor }));
|
||||
const tagCatalog = (tcatRes.data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
|
||||
// Monta mapas finais
|
||||
const gById = new Map(groupCatalog.map((g) => [g.id, g]));
|
||||
const groupsByPatient = new Map();
|
||||
for (const rel of pg || []) {
|
||||
for (const rel of pg) {
|
||||
const arr = groupsByPatient.get(rel.patient_id) || [];
|
||||
const g = gById.get(rel.patient_group_id);
|
||||
if (g) arr.push(g);
|
||||
groupsByPatient.set(rel.patient_id, arr);
|
||||
}
|
||||
|
||||
const { data: pt, error: ptErr } = await supabase.from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', ids);
|
||||
if (ptErr) throw ptErr;
|
||||
|
||||
const tagIds = Array.from(new Set((pt || []).map((r) => r.tag_id).filter(Boolean)));
|
||||
let tagCatalog = [];
|
||||
if (tagIds.length) {
|
||||
let tq = supabase.from('patient_tags').select('id, nome, cor').in('id', tagIds);
|
||||
tq = withOwnerFilter(tq);
|
||||
const { data: tcatData, error: tErr } = await tq;
|
||||
if (tErr) throw tErr;
|
||||
tagCatalog = (tcatData || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||
}
|
||||
|
||||
const tById = new Map(tagCatalog.map((t) => [t.id, t]));
|
||||
const tagsByPatient = new Map();
|
||||
for (const rel of pt || []) {
|
||||
for (const rel of pt) {
|
||||
const arr = tagsByPatient.get(rel.patient_id) || [];
|
||||
const t = tById.get(rel.tag_id);
|
||||
if (t) arr.push(t);
|
||||
@@ -606,15 +631,12 @@ async function hydrateAssociationsSupabase() {
|
||||
tags: tagsByPatient.get(p.id) || []
|
||||
}));
|
||||
|
||||
// Calcula historySet — uma única query para todos os ids
|
||||
const { data: evtCounts } = await supabase.from('agenda_eventos').select('patient_id').in('patient_id', ids).not('patient_id', 'is', null).limit(1000);
|
||||
|
||||
const tempSet = new Set();
|
||||
const countMap = new Map();
|
||||
for (const r of evtCounts || []) {
|
||||
if (r.patient_id) {
|
||||
for (const r of sessionCounts) {
|
||||
if (r.patient_id && r.session_count > 0) {
|
||||
tempSet.add(r.patient_id);
|
||||
countMap.set(r.patient_id, (countMap.get(r.patient_id) || 0) + 1);
|
||||
countMap.set(r.patient_id, r.session_count);
|
||||
}
|
||||
}
|
||||
historySet.value = tempSet;
|
||||
|
||||
@@ -1,965 +0,0 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| 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>
|
||||
@@ -1,679 +0,0 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| 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>
|
||||
@@ -65,6 +65,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { logError } from '@/support/supportLogger'
|
||||
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
|
||||
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
|
||||
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
|
||||
@@ -91,6 +92,11 @@ const tenantStore = useTenantStore()
|
||||
async function getCurrentTenantId () {
|
||||
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
}
|
||||
|
||||
// Helper sync para blindar queries com .eq('tenant_id', ...) — defesa em profundidade.
|
||||
function currentTenantId () {
|
||||
return tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
|
||||
}
|
||||
async function getCurrentMemberId (tenantId) {
|
||||
const { data: a, error: ae } = await supabase.auth.getUser(); if (ae) throw ae
|
||||
const uid = a?.user?.id; if (!uid) throw new Error('Sessão inválida.')
|
||||
@@ -480,29 +486,26 @@ async function loadContatosSuporte (pid) {
|
||||
// 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]||{}
|
||||
if ('nome' in row||'cor' in row) {
|
||||
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}))
|
||||
}
|
||||
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 tid = currentTenantId()
|
||||
let q = supabase.from('patient_groups').select('id,nome,descricao,cor,is_system,is_active').eq('is_active',true).order('nome',{ascending:true})
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q
|
||||
if (error) throw error
|
||||
return (data||[]).map(g=>({...g,name:g.nome,color:g.cor}))
|
||||
}
|
||||
async function listTags () {
|
||||
const probe = await supabase.from('patient_tags').select('*').limit(1)
|
||||
if (probe.error) throw probe.error
|
||||
const row = probe.data?.[0]||{}
|
||||
if ('name' in row||'color' in row) {
|
||||
const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name',{ascending:true})
|
||||
if (error) throw error; return data||[]
|
||||
}
|
||||
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 tid = currentTenantId()
|
||||
let q = supabase.from('patient_tags').select('id,nome,cor').order('nome',{ascending:true})
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q
|
||||
if (error) throw error
|
||||
return (data||[]).map(t=>({...t,name:t.nome,color:t.cor}))
|
||||
}
|
||||
async function getPatientById (id) {
|
||||
const { data, error } = await supabase.from('patients').select('*').eq('id',id).single()
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patients').select('*').eq('id',id)
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { data, error } = await q.maybeSingle()
|
||||
if (error) throw error; return data
|
||||
}
|
||||
async function getPatientRelations (id) {
|
||||
@@ -515,7 +518,10 @@ async function createPatient (payload) {
|
||||
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)
|
||||
const tid = currentTenantId()
|
||||
let q = supabase.from('patients').update({ ...payload, updated_at:new Date().toISOString() }).eq('id',id)
|
||||
if (tid) q = q.eq('tenant_id', tid)
|
||||
const { error } = await q
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
@@ -628,7 +634,7 @@ async function onSubmit () {
|
||||
convenioId.value=null; convenioNome.value=''; medicosSelecionados.value=[]
|
||||
await openPanel(0)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
logError('patients.cadastro', 'save falhou', e)
|
||||
toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 })
|
||||
} finally { saving.value=false }
|
||||
}
|
||||
|
||||
@@ -66,55 +66,23 @@ const publicUrl = computed(() => {
|
||||
});
|
||||
|
||||
// ── Token helpers ─────────────────────────────────────────
|
||||
function newToken() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
async function requireUserId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado');
|
||||
return uid;
|
||||
}
|
||||
// A#18 + A#23: tokens são gerados NO SERVIDOR via gen_random_uuid(). O cliente
|
||||
// NUNCA gera tokens — elimina fallback inseguro com Math.random().
|
||||
|
||||
async function loadOrCreateInvite() {
|
||||
const uid = await requireUserId();
|
||||
|
||||
const { data, error } = await supabase.from('patient_invites').select('token, active').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
|
||||
|
||||
// RPC issue_patient_invite retorna o token ativo existente ou cria um novo
|
||||
// (servidor resolve uid via auth.uid() + tenant_id via tenant_members).
|
||||
const { data, error } = await supabase.rpc('issue_patient_invite');
|
||||
if (error) throw error;
|
||||
|
||||
const token = data?.[0]?.token;
|
||||
if (token) {
|
||||
inviteToken.value = token;
|
||||
return;
|
||||
}
|
||||
|
||||
const t = newToken();
|
||||
const { error: insErr } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
|
||||
|
||||
if (insErr) throw insErr;
|
||||
inviteToken.value = t;
|
||||
inviteToken.value = data;
|
||||
}
|
||||
|
||||
async function rotateLink() {
|
||||
rotating.value = true;
|
||||
try {
|
||||
const uid = await requireUserId();
|
||||
const t = newToken();
|
||||
|
||||
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t });
|
||||
if (rpc.error) {
|
||||
const { error: e1 } = await supabase.from('patient_invites').update({ active: false, updated_at: new Date().toISOString() }).eq('owner_id', uid).eq('active', true);
|
||||
if (e1) throw e1;
|
||||
|
||||
const { error: e2 } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
|
||||
if (e2) throw e2;
|
||||
}
|
||||
|
||||
inviteToken.value = t;
|
||||
const { data, error } = await supabase.rpc('rotate_patient_invite_token_v2');
|
||||
if (error) throw error;
|
||||
inviteToken.value = data;
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado. O anterior foi revogado.', life: 2500 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 });
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<script setup>
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
@@ -282,7 +283,7 @@ async function fetchIntakes() {
|
||||
});
|
||||
avatarCache.clear();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError('patients.recebidos', 'fetchIntakes falhou', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -325,7 +326,7 @@ async function markRejected() {
|
||||
const updated = rows.value.find((r) => r.id === item.id);
|
||||
if (updated) openDetails(updated);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError('patients.recebidos', 'saveDetails falhou', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 3500 });
|
||||
} finally {
|
||||
dlg.value.saving = false;
|
||||
@@ -413,7 +414,7 @@ async function convertToPatient() {
|
||||
dlg.value.open = false;
|
||||
await fetchIntakes();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
logError('patients.recebidos', 'converter paciente falhou', err);
|
||||
toast.add({ severity: 'error', summary: 'Falha ao converter', detail: err?.message || 'Não foi possível converter o cadastro.', life: 4500 });
|
||||
} finally {
|
||||
converting.value = false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,667 +0,0 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| 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>
|
||||
@@ -25,6 +25,7 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
@@ -223,7 +224,7 @@ async function buscarEtiquetas() {
|
||||
if (t.error) throw t.error;
|
||||
etiquetas.value = (t.data || []).map((r) => normalizarEtiquetaRow({ ...r, pacientes_count: 0 }));
|
||||
} catch (e) {
|
||||
console.error('[TagsPage] buscarEtiquetas error', e);
|
||||
logError('patients.tags', 'buscarEtiquetas', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar tags', detail: e?.message || 'Verifique se as tabelas/views existem.', life: 6000 });
|
||||
} finally {
|
||||
carregando.value = false;
|
||||
@@ -276,7 +277,7 @@ async function salvarDlg() {
|
||||
dlg.open = false;
|
||||
await buscarEtiquetas();
|
||||
} catch (e) {
|
||||
console.error('[TagsPage] salvarDlg error', e);
|
||||
logError('patients.tags', 'salvarDlg', e);
|
||||
if (isUniqueViolation(e)) {
|
||||
toast.add({ severity: 'warn', summary: 'Tag já existe', detail: `Já existe uma tag chamada "${String(dlg.nome || '').trim()}". Tente outro nome.`, life: 4500 });
|
||||
return;
|
||||
@@ -333,7 +334,7 @@ async function excluirTags(rows) {
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: `${ids.length} tag(s) removida(s).`, life: 3000 });
|
||||
await buscarEtiquetas();
|
||||
} catch (e) {
|
||||
console.error('[TagsPage] excluirTags error', e);
|
||||
logError('patients.tags', 'excluirTags', e);
|
||||
toast.add({ severity: 'error', summary: 'Não consegui excluir', detail: e?.message || 'Erro ao excluir tags.', life: 6000 });
|
||||
}
|
||||
}
|
||||
@@ -366,7 +367,7 @@ async function carregarPacientesDaTag(tag) {
|
||||
.map((p) => ({ id: p.id, name: p.nome_completo || '—', email: p.email_principal || '—', phone: p.telefone || '—', avatar_url: p.avatar_url || null }))
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'pt-BR'));
|
||||
} catch (e) {
|
||||
console.error('[TagsPage] carregarPacientesDaTag error', e);
|
||||
logError('patients.tags', 'carregarPacientesDaTag', e);
|
||||
modalPacientes.error = e?.message || 'Não consegui carregar os pacientes desta tag.';
|
||||
} finally {
|
||||
modalPacientes.loading = false;
|
||||
|
||||
@@ -61,6 +61,8 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
||||
label: 'Operações',
|
||||
items: [
|
||||
{ label: 'Clínicas (Tenants)', icon: 'pi pi-fw pi-users', to: '/saas/tenants' },
|
||||
{ label: 'Recursos por Clínica', icon: 'pi pi-fw pi-key', to: '/saas/tenant-features' },
|
||||
{ label: 'Segurança / Bots', icon: 'pi pi-fw pi-shield', to: '/saas/security' },
|
||||
{ label: 'Feriados', icon: 'pi pi-fw pi-star', to: '/saas/feriados' },
|
||||
{ label: 'Suporte Técnico', icon: 'pi pi-fw pi-headphones', to: '/saas/support' }
|
||||
]
|
||||
@@ -71,6 +73,7 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
||||
items: [
|
||||
{ label: 'WhatsApp (Evolution API)', icon: 'pi pi-fw pi-whatsapp', to: '/saas/whatsapp' },
|
||||
{ label: 'WhatsApp Twilio (Subcontas)', icon: 'pi pi-fw pi-whatsapp', to: '/saas/twilio-whatsapp' },
|
||||
{ label: 'Config Twilio (Master)', icon: 'pi pi-fw pi-cog', to: '/saas/twilio-config' },
|
||||
{ label: 'Templates WhatsApp/SMS', icon: 'pi pi-fw pi-comment', to: '/saas/notification-templates' },
|
||||
{ label: 'Add-ons / Créditos SMS', icon: 'pi pi-fw pi-box', to: '/saas/addons' }
|
||||
]
|
||||
@@ -91,6 +94,11 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
||||
{ 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' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Desenvolvimento',
|
||||
items: [{ label: 'Área de Dev', icon: 'pi pi-fw pi-code', to: '/saas/desenvolvimento' }]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* guards.spec.js
|
||||
*
|
||||
* Cobre as funções puras extraídas de guards.js. O beforeEach inteiro
|
||||
* (applyGuards) não é unit-testado aqui — exige mock de pinia + supabase +
|
||||
* vue-router. Cobertura completa de navegação fica para testes E2E (T#10).
|
||||
*
|
||||
* Mocks: supabase e stores são mockados via vi.mock para permitir que o
|
||||
* módulo guards.js carregue sem explodir — mas os testes focam nas puras.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mocks antes do import do SUT
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }),
|
||||
onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
|
||||
},
|
||||
from: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/tenantStore', () => ({ useTenantStore: vi.fn() }));
|
||||
vi.mock('@/stores/entitlementsStore', () => ({ useEntitlementsStore: vi.fn() }));
|
||||
vi.mock('@/stores/tenantFeaturesStore', () => ({ useTenantFeaturesStore: vi.fn() }));
|
||||
vi.mock('@/stores/menuStore', () => ({ useMenuStore: vi.fn() }));
|
||||
vi.mock('@/navigation', () => ({ getMenuByRole: vi.fn().mockReturnValue([]) }));
|
||||
vi.mock('@/utils/upgradeContext', () => ({ buildUpgradeUrl: vi.fn() }));
|
||||
vi.mock('@/support/supportLogger', () => ({
|
||||
logGuard: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logPerf: vi.fn(() => () => {})
|
||||
}));
|
||||
vi.mock('@/composables/useAjuda', () => ({ resetAjuda: vi.fn() }));
|
||||
vi.mock('@/app/session', () => ({
|
||||
sessionUser: { value: null },
|
||||
sessionReady: { value: true },
|
||||
sessionRefreshing: { value: false },
|
||||
initSession: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
vi.mock('@/router/accessRedirects', () => ({
|
||||
denyByRole: vi.fn(({ to }) => ({ path: '/pages/access' })),
|
||||
denyByPlan: vi.fn()
|
||||
}));
|
||||
|
||||
const { isUuid, roleToPath, shouldLoadEntitlements, matchesRoles } = await import('../guards.js');
|
||||
|
||||
describe('isUuid — validação UUID v1-5 case insensitive', () => {
|
||||
it('aceita UUIDs v4 canônicos', () => {
|
||||
expect(isUuid('550e8400-e29b-41d4-a716-446655440000')).toBe(true);
|
||||
expect(isUuid('f47ac10b-58cc-4372-a567-0e02b2c3d479')).toBe(true);
|
||||
});
|
||||
|
||||
it('aceita uppercase', () => {
|
||||
expect(isUuid('550E8400-E29B-41D4-A716-446655440000')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita strings vazias/null', () => {
|
||||
expect(isUuid('')).toBe(false);
|
||||
expect(isUuid(null)).toBe(false);
|
||||
expect(isUuid(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita formatos truncados/sem hífen', () => {
|
||||
expect(isUuid('550e8400e29b41d4a716446655440000')).toBe(false); // sem hífen
|
||||
expect(isUuid('550e8400-e29b-41d4-a716')).toBe(false); // truncado
|
||||
expect(isUuid('not-a-uuid')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita version 6+ e variant inválido', () => {
|
||||
// variant precisa começar com 8, 9, a ou b
|
||||
expect(isUuid('550e8400-e29b-41d4-c716-446655440000')).toBe(false);
|
||||
// version precisa ser 1-5
|
||||
expect(isUuid('550e8400-e29b-61d4-a716-446655440000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleToPath — mapa de role → home path', () => {
|
||||
it('mapeia clinic_admin/tenant_admin/admin → /admin', () => {
|
||||
expect(roleToPath('clinic_admin')).toBe('/admin');
|
||||
expect(roleToPath('tenant_admin')).toBe('/admin');
|
||||
expect(roleToPath('admin')).toBe('/admin');
|
||||
});
|
||||
|
||||
it('mapeia therapist → /therapist', () => {
|
||||
expect(roleToPath('therapist')).toBe('/therapist');
|
||||
});
|
||||
|
||||
it('mapeia supervisor → /supervisor', () => {
|
||||
expect(roleToPath('supervisor')).toBe('/supervisor');
|
||||
});
|
||||
|
||||
it('mapeia patient/portal_user → /portal', () => {
|
||||
expect(roleToPath('patient')).toBe('/portal');
|
||||
expect(roleToPath('portal_user')).toBe('/portal');
|
||||
});
|
||||
|
||||
it('mapeia saas_admin → /saas', () => {
|
||||
expect(roleToPath('saas_admin')).toBe('/saas');
|
||||
});
|
||||
|
||||
it('fallback para /', () => {
|
||||
expect(roleToPath('unknown')).toBe('/');
|
||||
expect(roleToPath('')).toBe('/');
|
||||
expect(roleToPath(null)).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldLoadEntitlements — heurística de carga de entitlements', () => {
|
||||
it('retorna false sem tenantId', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, null)).toBe(false);
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna true se nunca carregou', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: false }, 't1')).toBe(true);
|
||||
expect(shouldLoadEntitlements({}, 't1')).toBe(true); // loaded undefined
|
||||
});
|
||||
|
||||
it('retorna true se tenant mudou', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, 't2')).toBe(true);
|
||||
expect(shouldLoadEntitlements({ loaded: true, tenantId: 't1' }, 't2')).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false se já carregado pro mesmo tenant', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, 't1')).toBe(false);
|
||||
expect(shouldLoadEntitlements({ loaded: true, tenantId: 't1' }, 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('loaded=true sem tenantId anterior ainda exige carga se target existe', () => {
|
||||
// edge case: loaded=true mas sem activeTenantId (cenário inconsistente) →
|
||||
// heurística conservadora retorna false (não recarrega)
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: null }, 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesRoles — RBAC com normalização de aliases', () => {
|
||||
it('retorna true se lista vazia/null (sem restrição)', () => {
|
||||
expect(matchesRoles(null, 'therapist')).toBe(true);
|
||||
expect(matchesRoles([], 'therapist')).toBe(true);
|
||||
expect(matchesRoles(undefined, 'any')).toBe(true);
|
||||
});
|
||||
|
||||
it('bate role canônico', () => {
|
||||
expect(matchesRoles(['therapist'], 'therapist')).toBe(true);
|
||||
expect(matchesRoles(['clinic_admin', 'therapist'], 'therapist')).toBe(true);
|
||||
});
|
||||
|
||||
it('bate via normalização — tenant_admin contexto therapist tenant_admin equiv clinic_admin default', () => {
|
||||
// Sem kind, tenant_admin vira clinic_admin
|
||||
expect(matchesRoles(['clinic_admin'], 'tenant_admin')).toBe(true);
|
||||
expect(matchesRoles(['admin'], 'tenant_admin')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita role não listado', () => {
|
||||
expect(matchesRoles(['therapist'], 'clinic_admin')).toBe(false);
|
||||
expect(matchesRoles(['saas_admin'], 'therapist')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignora não-arrays', () => {
|
||||
expect(matchesRoles('therapist', 'therapist')).toBe(true);
|
||||
expect(matchesRoles({}, 'therapist')).toBe(true);
|
||||
});
|
||||
});
|
||||
+119
-121
@@ -28,11 +28,14 @@ import { resetAjuda } from '@/composables/useAjuda';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { getMenuByRole } from '@/navigation';
|
||||
|
||||
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session';
|
||||
import { sessionUser, sessionReady, sessionRefreshing, initSession, onSessionEvent } from '@/app/session';
|
||||
|
||||
// ✅ separa RBAC (papel) vs Plano (upgrade)
|
||||
import { denyByRole, denyByPlan } from '@/router/accessRedirects'; // (denyByPlan pode ficar, mesmo que não use aqui)
|
||||
|
||||
// ✅ única fonte de verdade pra normalizar role
|
||||
import { normalizeRole } from '@/utils/roleNormalizer';
|
||||
|
||||
// cache simples (evita bater no banco em toda navegação)
|
||||
let sessionUidCache = null;
|
||||
|
||||
@@ -40,9 +43,18 @@ let sessionUidCache = null;
|
||||
let saasAdminCacheUid = null;
|
||||
let saasAdminCacheIsAdmin = null;
|
||||
|
||||
// cache de globalRole por uid (evita query ao banco em cada navegação)
|
||||
// V#6 — cache de globalRole por uid com TTL.
|
||||
// Antes era invalidado apenas em SIGNED_IN/SIGNED_OUT, ficando stale se a role
|
||||
// mudasse durante a sessão. TTL de 5min força re-fetch periódico.
|
||||
const GLOBAL_ROLE_TTL_MS = 5 * 60 * 1000;
|
||||
let globalRoleCacheUid = null;
|
||||
let globalRoleCache = null;
|
||||
let globalRoleCacheAt = 0;
|
||||
|
||||
// Flags module-level para garantir single-bind (ES modules são singletons,
|
||||
// então basta uma variável aqui — não precisa poluir window).
|
||||
let guardsBound = false;
|
||||
let authListenerBound = false;
|
||||
|
||||
// -----------------------------------------
|
||||
// Pending invite (Modelo B) — retomada pós-login
|
||||
@@ -63,38 +75,12 @@ function clearPendingInviteToken() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function isUuid(v) {
|
||||
// Exportadas pra permitir teste unitário sem montar o router inteiro.
|
||||
export function isUuid(v) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Normaliza roles (aliases) para RBAC.
|
||||
*
|
||||
* tenant_admin / admin + kind = 'therapist' → 'therapist'
|
||||
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
|
||||
* qualquer outro role → pass-through
|
||||
*/
|
||||
function normalizeRole(role, kind) {
|
||||
const r = String(role || '').trim();
|
||||
if (!r) return '';
|
||||
|
||||
const isAdmin = r === 'tenant_admin' || r === 'admin';
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim();
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist';
|
||||
if (k === 'supervisor') return 'supervisor';
|
||||
return 'clinic_admin';
|
||||
}
|
||||
|
||||
if (r === 'clinic_admin') return 'clinic_admin';
|
||||
|
||||
// demais
|
||||
return r;
|
||||
}
|
||||
|
||||
function roleToPath(role) {
|
||||
export function roleToPath(role) {
|
||||
// ✅ clínica: aceita nomes canônicos e legado
|
||||
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin';
|
||||
|
||||
@@ -150,7 +136,7 @@ async function isSaasAdmin(uid) {
|
||||
}
|
||||
|
||||
// heurística segura: carrega entitlements se ainda não carregou ou mudou tenant
|
||||
function shouldLoadEntitlements(ent, tenantId) {
|
||||
export function shouldLoadEntitlements(ent, tenantId) {
|
||||
if (!tenantId) return false;
|
||||
|
||||
const loaded = typeof ent.loaded === 'boolean' ? ent.loaded : false;
|
||||
@@ -193,7 +179,7 @@ async function fetchTenantFeaturesSafe(tf, tenantId) {
|
||||
}
|
||||
|
||||
// util: roles guard (plural) com aliases
|
||||
function matchesRoles(roles, activeRole) {
|
||||
export function matchesRoles(roles, activeRole) {
|
||||
if (!Array.isArray(roles) || !roles.length) return true;
|
||||
|
||||
const ar = normalizeRole(activeRole);
|
||||
@@ -207,10 +193,12 @@ function matchesRoles(roles, activeRole) {
|
||||
// - O AppMenu lê menuStore.model e não recalcula.
|
||||
// ======================================================
|
||||
|
||||
// V#9 router — skip-fast: evita useMenuStore() + comparações quando o último
|
||||
// key processado é o mesmo. Reset em SIGNED_OUT/SIGNED_IN garante invalidação.
|
||||
let lastEnsureKey = null;
|
||||
|
||||
async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const isSaas = globalRole === 'saas_admin';
|
||||
const roleForMenu = isSaas ? 'saas_admin' : normalizeRole(tenantRole);
|
||||
|
||||
@@ -222,6 +210,11 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
|
||||
const safeGlobal = globalRole || 'no-global';
|
||||
const key = `${uid}:${safeTenant}:${safeRole}:${safeGlobal}`;
|
||||
|
||||
// V#9 — short-circuit: mesmo key da última chamada → menu já construído nessa nav
|
||||
if (lastEnsureKey === key) return;
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
// ✅ FIX PRINCIPAL: só considera cache válido se role E tenant baterem.
|
||||
// Antes, o check era feito antes de garantir que tenant.activeRole
|
||||
// já tinha sido resolvido corretamente nessa navegação.
|
||||
@@ -254,6 +247,7 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
|
||||
(!expectClinic && !expectTherapist && !expectSupervisor && !expectEditor && !expectPortal && !expectSaas);
|
||||
|
||||
if (menuMatchesRole) {
|
||||
lastEnsureKey = key;
|
||||
return; // cache válido e menu correto
|
||||
}
|
||||
|
||||
@@ -292,14 +286,20 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
|
||||
|
||||
const model = getMenuByRole(roleForMenu, ctx) || [];
|
||||
menuStore.setMenu(key, model);
|
||||
lastEnsureKey = key;
|
||||
} catch (e) {
|
||||
logGuard('[guards] ensureMenuBuilt failed', { error: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
// V#9 — invalida o short-circuit do ensureMenuBuilt (chamado em SIGNED_IN/OUT)
|
||||
function resetEnsureMenuKey() {
|
||||
lastEnsureKey = null;
|
||||
}
|
||||
|
||||
export function applyGuards(router) {
|
||||
if (window.__guardsBound) return;
|
||||
window.__guardsBound = true;
|
||||
if (guardsBound) return;
|
||||
guardsBound = true;
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const tlabel = `[guard] ${to.fullPath}`;
|
||||
@@ -353,7 +353,10 @@ export function applyGuards(router) {
|
||||
// ======================================
|
||||
let globalRole = null;
|
||||
|
||||
if (globalRoleCacheUid === uid && globalRoleCache) {
|
||||
const cacheAge = Date.now() - globalRoleCacheAt;
|
||||
const cacheValid = globalRoleCacheUid === uid && globalRoleCache && cacheAge < GLOBAL_ROLE_TTL_MS;
|
||||
|
||||
if (cacheValid) {
|
||||
globalRole = globalRoleCache;
|
||||
logGuard('profiles.role (cache) =', globalRole);
|
||||
} else {
|
||||
@@ -363,6 +366,7 @@ export function applyGuards(router) {
|
||||
if (globalRole) {
|
||||
globalRoleCacheUid = uid;
|
||||
globalRoleCache = globalRole;
|
||||
globalRoleCacheAt = Date.now();
|
||||
}
|
||||
logGuard('profiles.role (db) =', globalRole);
|
||||
}
|
||||
@@ -479,6 +483,7 @@ export function applyGuards(router) {
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
globalRoleCacheAt = 0;
|
||||
|
||||
const ent0 = useEntitlementsStore();
|
||||
if (typeof ent0.invalidate === 'function') ent0.invalidate();
|
||||
@@ -556,10 +561,15 @@ export function applyGuards(router) {
|
||||
const isSaas = globalRole === 'saas_admin';
|
||||
|
||||
if (isSaas) {
|
||||
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/');
|
||||
// V#10 — usa meta.area como fonte primária; path.startsWith vira fallback
|
||||
// pra rotas legacy que ainda não declaram meta.area. matchedRouteHasArea()
|
||||
// checa a cadeia inteira de matched (lida com routes aninhadas).
|
||||
const matchedHasSaasArea = (to.matched || []).some((r) => r.meta?.area === 'saas' || r.meta?.saasAdmin === true);
|
||||
const matchedHasDemoArea = (to.matched || []).some((r) => r.meta?.area === 'demo');
|
||||
const isSaasArea = matchedHasSaasArea || to.path === '/saas' || to.path.startsWith('/saas/');
|
||||
|
||||
// Rotas do Tema Demo (no seu caso ficam em /demo/*)
|
||||
const isDemoArea = import.meta.env.DEV && (to.path === '/demo' || to.path.startsWith('/demo/'));
|
||||
// Rotas do Tema Demo (em DEV)
|
||||
const isDemoArea = import.meta.env.DEV && (matchedHasDemoArea || to.path === '/demo' || to.path.startsWith('/demo/'));
|
||||
|
||||
// Se for demo em DEV, libera
|
||||
if (isDemoArea) {
|
||||
@@ -842,93 +852,81 @@ export function applyGuards(router) {
|
||||
}
|
||||
});
|
||||
|
||||
// auth listener (reset caches) — ✅ agora com filtro de evento
|
||||
if (!window.__supabaseAuthListenerBound) {
|
||||
window.__supabaseAuthListenerBound = true;
|
||||
// V#2 — listener consolidado: session.js é o único registrante de
|
||||
// supabase.auth.onAuthStateChange. Aqui só nos inscrevemos via onSessionEvent.
|
||||
if (!authListenerBound) {
|
||||
authListenerBound = true;
|
||||
|
||||
supabase.auth.onAuthStateChange((event, sess) => {
|
||||
// ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
|
||||
const uid = sess?.user?.id || null;
|
||||
// SIGNED_OUT: zera caches e localStorage tenant
|
||||
onSessionEvent('SIGNED_OUT', () => {
|
||||
sessionUidCache = null;
|
||||
saasAdminCacheUid = null;
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
globalRoleCacheAt = 0;
|
||||
resetEnsureMenuKey();
|
||||
|
||||
// ✅ SIGNED_OUT: aqui sim zera tudo
|
||||
if (event === 'SIGNED_OUT') {
|
||||
sessionUidCache = null;
|
||||
saasAdminCacheUid = null;
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
try { resetAjuda(); } catch (_) { }
|
||||
|
||||
try { resetAjuda(); } catch (_) { }
|
||||
// limpa localStorage de tenant — sem isso, próximo login restaura
|
||||
// o tenant do usuário anterior (mesma máquina)
|
||||
try {
|
||||
localStorage.removeItem('tenant_id');
|
||||
localStorage.removeItem('tenant');
|
||||
localStorage.removeItem('currentTenantId');
|
||||
} catch (_) { }
|
||||
|
||||
// ✅ FIX: limpa o localStorage de tenant na saída
|
||||
// Sem isso, o próximo login restaura o tenant do usuário anterior.
|
||||
try {
|
||||
localStorage.removeItem('tenant_id');
|
||||
localStorage.removeItem('tenant');
|
||||
localStorage.removeItem('currentTenantId');
|
||||
} catch (_) { }
|
||||
try {
|
||||
const tf = useTenantFeaturesStore();
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore();
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate();
|
||||
} catch { }
|
||||
try {
|
||||
const ent = useEntitlementsStore();
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore();
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate();
|
||||
} catch { }
|
||||
try {
|
||||
const tenant = useTenantStore();
|
||||
if (typeof tenant.reset === 'function') tenant.reset();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const tenant = useTenantStore();
|
||||
if (typeof tenant.reset === 'function') tenant.reset();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch { }
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ TOKEN_REFRESHED: NÃO invalida nada (é o caso clássico de trocar de aba)
|
||||
if (event === 'TOKEN_REFRESHED') return;
|
||||
|
||||
// ✅ SIGNED_IN / USER_UPDATED:
|
||||
// só invalida se o usuário mudou de verdade
|
||||
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
||||
if (uid && sessionUidCache && sessionUidCache === uid) {
|
||||
// mesmo usuário -> não derruba caches
|
||||
return;
|
||||
}
|
||||
|
||||
// user mudou (ou cache vazio) -> invalida dependências
|
||||
sessionUidCache = uid || null;
|
||||
saasAdminCacheUid = null;
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore();
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore();
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch { }
|
||||
|
||||
// tenantStore carrega de novo no fluxo do guard quando precisar
|
||||
return;
|
||||
}
|
||||
|
||||
// default: não faz nada
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch { }
|
||||
});
|
||||
|
||||
// SIGNED_IN: só invalida se o usuário mudou de verdade
|
||||
onSessionEvent('SIGNED_IN', (sess) => {
|
||||
const uid = sess?.user?.id || null;
|
||||
if (uid && sessionUidCache && sessionUidCache === uid) return; // mesmo user
|
||||
|
||||
sessionUidCache = uid || null;
|
||||
saasAdminCacheUid = null;
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
globalRoleCacheAt = 0;
|
||||
resetEnsureMenuKey();
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore();
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore();
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate();
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore();
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset();
|
||||
} catch { }
|
||||
});
|
||||
|
||||
// TOKEN_REFRESHED: não invalida nada (caso clássico de trocar de aba)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ import miscRoutes from './routes.misc';
|
||||
|
||||
import { pinia } from '@/plugins/pinia';
|
||||
import { supportGuard } from '@/support/supportGuard';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
import { applyGuards } from './guards';
|
||||
|
||||
const routes = [
|
||||
@@ -97,7 +98,7 @@ const router = createRouter({
|
||||
}
|
||||
});
|
||||
|
||||
router.onError((e) => console.error('[router.onError]', e));
|
||||
router.onError((e) => logError('router.onError', 'router error', e));
|
||||
|
||||
// ✅ support guard — passa pinia para garantir acesso ao store antes do app.use(pinia)
|
||||
router.beforeEach(async (to) => {
|
||||
|
||||
@@ -77,6 +77,16 @@ export default {
|
||||
name: 'saas-tenants',
|
||||
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
|
||||
},
|
||||
{
|
||||
path: 'tenant-features',
|
||||
name: 'saas-tenant-features',
|
||||
component: () => import('@/views/pages/saas/SaasTenantFeaturesPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
name: 'saas-security',
|
||||
component: () => import('@/views/pages/saas/SaasSecurityPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'feriados',
|
||||
name: 'saas-feriados',
|
||||
@@ -133,6 +143,12 @@ export default {
|
||||
component: () => import('@/views/pages/saas/SaasTwilioWhatsappPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'twilio-config',
|
||||
name: 'saas-twilio-config',
|
||||
component: () => import('@/views/pages/saas/SaasTwilioConfigPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'addons',
|
||||
name: 'saas-addons',
|
||||
@@ -144,6 +160,12 @@ export default {
|
||||
name: 'saas-document-templates',
|
||||
component: () => import('@/views/pages/saas/SaasDocumentTemplatesPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'desenvolvimento',
|
||||
name: 'saas-desenvolvimento',
|
||||
component: () => import('@/views/pages/saas/development/SaasDevelopmentPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -105,36 +105,23 @@ export async function listShareLinks(documentoId) {
|
||||
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();
|
||||
// V#46: SELECT direto pela tabela com policy "public read by token" foi
|
||||
// removido. Agora chama RPC validate_share_token (SECURITY DEFINER) que
|
||||
// valida + incrementa usos + loga acesso atomicamente. Sem race condition.
|
||||
const { data, error } = await supabase.rpc('validate_share_token', { p_token: token });
|
||||
if (error) return null;
|
||||
if (!data?.document_id) return null;
|
||||
|
||||
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 };
|
||||
return {
|
||||
link: { id: null, token, documento_id: data.document_id }, // estrutura mínima compat
|
||||
document: {
|
||||
id: data.document_id,
|
||||
nome_original: data.nome_original,
|
||||
mime_type: data.mime_type,
|
||||
bucket_path: data.bucket_path,
|
||||
storage_bucket: data.bucket
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Desativar link ──────────────────────────────────────────
|
||||
|
||||
@@ -16,12 +16,57 @@ const PROVISION_FN = 'twilio-whatsapp-provision'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mensagens user-friendly para erros conhecidos da edge function.
|
||||
// Mantém a mensagem técnica como fallback.
|
||||
function friendlyErrorMessage(message) {
|
||||
const msg = String(message || '').toLowerCase()
|
||||
|
||||
if (msg.includes('credenciais twilio')) {
|
||||
return 'Credenciais Twilio não configuradas. Configure TWILIO_ACCOUNT_SID e TWILIO_AUTH_TOKEN nas variáveis da Edge Function antes de usar.'
|
||||
}
|
||||
if (msg.includes('não autorizado') || msg.includes('not authorized')) {
|
||||
return 'Sessão expirada ou sem permissão. Faça login de novo.'
|
||||
}
|
||||
if (msg.includes('apenas saas admin')) {
|
||||
return 'Apenas SaaS admins podem executar esta ação.'
|
||||
}
|
||||
if (msg.includes('failed to send') || msg.includes('networkerror') || msg.includes('failed to fetch')) {
|
||||
return 'Edge Function indisponível. Verifique se foi feito deploy (npx supabase functions deploy twilio-whatsapp-provision) e tente novamente.'
|
||||
}
|
||||
if (msg.includes('non-2xx')) {
|
||||
return 'A Edge Function rejeitou a chamada. Veja "Configuração de variáveis" abaixo — provavelmente faltam credenciais Twilio.'
|
||||
}
|
||||
if (msg.includes('not found') && msg.includes('function')) {
|
||||
return 'Edge Function não encontrada. Faça o deploy: npx supabase functions deploy twilio-whatsapp-provision'
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
async function callProvision(action, payload = {}) {
|
||||
const { data, error } = await supabase.functions.invoke(PROVISION_FN, {
|
||||
body: { action, ...payload },
|
||||
})
|
||||
if (error) throw new Error(error.message || 'Erro na Edge Function')
|
||||
if (data?.error) throw new Error(data.error)
|
||||
let data, error
|
||||
try {
|
||||
const res = await supabase.functions.invoke(PROVISION_FN, {
|
||||
body: { action, ...payload },
|
||||
})
|
||||
data = res.data
|
||||
error = res.error
|
||||
} catch (e) {
|
||||
// erro de rede / function indisponível
|
||||
throw new Error(friendlyErrorMessage(e?.message || 'Falha de conexão com a Edge Function'))
|
||||
}
|
||||
|
||||
// Quando edge devolve 4xx/5xx, supabase-js coloca a Response em error.context
|
||||
// O body com {error, detail} continua acessível via error.context.json().
|
||||
if (error) {
|
||||
let realMsg = error.message
|
||||
try {
|
||||
const ctxBody = await error.context?.json?.()
|
||||
if (ctxBody?.error) realMsg = ctxBody.error
|
||||
} catch { /* ignora — ficamos com error.message original */ }
|
||||
throw new Error(friendlyErrorMessage(realMsg))
|
||||
}
|
||||
|
||||
if (data?.error) throw new Error(friendlyErrorMessage(data.error))
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* tenantFeaturesStore.spec.js
|
||||
*
|
||||
* Cobertura V#34/V#41 — Opção B2 (plano + override com exceção comercial).
|
||||
*
|
||||
* isEnabled DEVE:
|
||||
* - sem tenantId/key → false (regressão V#34: opt-out por padrão)
|
||||
* - override negativo → false sempre
|
||||
* - override positivo → true sempre (exceção comercial)
|
||||
* - sem override + plano permite → true
|
||||
* - sem override + plano não permite → false
|
||||
* - sem override + user entitlement permite → true (fallback solo therapist)
|
||||
*
|
||||
* setForTenant DEVE:
|
||||
* - chamar supabase.rpc('set_tenant_feature_exception', ...) com payload correto
|
||||
* - propagar erro
|
||||
* - atualizar cache local em sucesso
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
const rpcMock = vi.fn();
|
||||
const fromMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: {
|
||||
from: (...args) => fromMock(...args),
|
||||
rpc: (...args) => rpcMock(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
// estado controlável do entitlementsStore
|
||||
const entState = {
|
||||
loadedForTenant: null,
|
||||
tenantFeatures: [],
|
||||
userFeatures: []
|
||||
};
|
||||
|
||||
vi.mock('@/stores/entitlementsStore', () => ({
|
||||
useEntitlementsStore: () => entState
|
||||
}));
|
||||
|
||||
const { useTenantFeaturesStore } = await import('../tenantFeaturesStore.js');
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
rpcMock.mockReset();
|
||||
fromMock.mockReset();
|
||||
entState.loadedForTenant = null;
|
||||
entState.tenantFeatures = [];
|
||||
entState.userFeatures = [];
|
||||
});
|
||||
|
||||
describe('isEnabled — guard rails', () => {
|
||||
it('retorna false sem tenantId nem loadedForTenantId', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
expect(s.isEnabled('patients')).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false sem key', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: true };
|
||||
s.loadedForTenantId = 't1';
|
||||
expect(s.isEnabled('')).toBe(false);
|
||||
expect(s.isEnabled(null, 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('regressão V#34: NÃO retorna true por padrão quando feature ausente', () => {
|
||||
// Antes (bug): sem entry em tenant_features → isEnabled = true
|
||||
// Agora (B2): sem entry + plano não permite → false
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {}; // sem entries
|
||||
s.loadedForTenantId = 't1';
|
||||
// entitlements vazio → não permite
|
||||
expect(s.isEnabled('patients', 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled — override explícito tem precedência', () => {
|
||||
it('override positivo → true mesmo se plano não permite (exceção comercial)', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { 'documents.signatures': true };
|
||||
s.loadedForTenantId = 't1';
|
||||
// entitlements vazio para esse tenant
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = [];
|
||||
expect(s.isEnabled('documents.signatures', 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('override negativo → false mesmo se plano permite (preferência cliente)', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: false };
|
||||
s.loadedForTenantId = 't1';
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = ['patients'];
|
||||
expect(s.isEnabled('patients', 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled — sem override → segue plano', () => {
|
||||
it('plano (tenant entitlement) permite → true', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = ['patients', 'agenda.view'];
|
||||
expect(s.isEnabled('patients', 't1')).toBe(true);
|
||||
expect(s.isEnabled('agenda.view', 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('plano não permite → false', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = ['patients'];
|
||||
expect(s.isEnabled('documents.signatures', 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('fallback user entitlement (solo therapist) → true', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
// tenant não tem subscription, mas user tem (therapist_pro)
|
||||
entState.loadedForTenant = null;
|
||||
entState.tenantFeatures = [];
|
||||
entState.userFeatures = ['patients.manage', 'documents.signatures'];
|
||||
expect(s.isEnabled('patients.manage', 't1')).toBe(true);
|
||||
expect(s.isEnabled('documents.signatures', 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('entitlements carregado para OUTRO tenant → ignora tenantFeatures (fail-safe)', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
// ent carregado para t2, não t1 → não confiar em tenantFeatures
|
||||
entState.loadedForTenant = 't2';
|
||||
entState.tenantFeatures = ['patients'];
|
||||
// userFeatures vazio também
|
||||
expect(s.isEnabled('patients', 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setForTenant — RPC obrigatória', () => {
|
||||
it('rejeita sem tenantId', async () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
await expect(s.setForTenant(null, 'patients', true)).rejects.toThrow(/tenantId/);
|
||||
});
|
||||
|
||||
it('rejeita sem key', async () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
await expect(s.setForTenant('t1', '', true)).rejects.toThrow(/feature_key/);
|
||||
});
|
||||
|
||||
it('chama RPC set_tenant_feature_exception com payload correto', async () => {
|
||||
rpcMock.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
|
||||
const s = useTenantFeaturesStore();
|
||||
await s.setForTenant('t1', 'patients', false, { reason: 'pref do cliente' });
|
||||
|
||||
expect(rpcMock).toHaveBeenCalledWith('set_tenant_feature_exception', {
|
||||
p_tenant_id: 't1',
|
||||
p_feature_key: 'patients',
|
||||
p_enabled: false,
|
||||
p_reason: 'pref do cliente'
|
||||
});
|
||||
});
|
||||
|
||||
it('aceita reason omitido (vira null)', async () => {
|
||||
rpcMock.mockResolvedValue({ data: null, error: null });
|
||||
const s = useTenantFeaturesStore();
|
||||
await s.setForTenant('t1', 'patients', true);
|
||||
expect(rpcMock).toHaveBeenCalledWith('set_tenant_feature_exception', {
|
||||
p_tenant_id: 't1',
|
||||
p_feature_key: 'patients',
|
||||
p_enabled: true,
|
||||
p_reason: null
|
||||
});
|
||||
});
|
||||
|
||||
it('propaga erro do RPC', async () => {
|
||||
rpcMock.mockResolvedValue({ data: null, error: new Error('Apenas saas_admin pode liberar feature fora do plano') });
|
||||
const s = useTenantFeaturesStore();
|
||||
await expect(s.setForTenant('t1', 'documents.signatures', true)).rejects.toThrow(/saas_admin/);
|
||||
expect(s.lastError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('atualiza cache local em sucesso', async () => {
|
||||
rpcMock.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
const s = useTenantFeaturesStore();
|
||||
await s.setForTenant('t1', 'patients', false, { reason: 'test' });
|
||||
// cache local refletiu
|
||||
expect(s.featuresByTenant['t1']?.patients).toBe(false);
|
||||
expect(s.loadedForTenantId).toBe('t1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate — limpeza por tenant', () => {
|
||||
it('sem tenantId limpa tudo', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: true };
|
||||
s.featuresByTenant['t2'] = { rooms: false };
|
||||
s.loadedForTenantId = 't1';
|
||||
s.invalidate();
|
||||
expect(Object.keys(s.featuresByTenant).length).toBe(0);
|
||||
expect(s.loadedForTenantId).toBe(null);
|
||||
});
|
||||
|
||||
it('com tenantId limpa só esse', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: true };
|
||||
s.featuresByTenant['t2'] = { rooms: false };
|
||||
s.loadedForTenantId = 't1';
|
||||
s.invalidate('t1');
|
||||
expect(s.featuresByTenant['t1']).toBeUndefined();
|
||||
expect(s.featuresByTenant['t2']).toEqual({ rooms: false });
|
||||
expect(s.loadedForTenantId).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* tenantStore.spec.js — T#5
|
||||
*
|
||||
* Cobre os caminhos críticos do tenantStore:
|
||||
* - ensureLoaded singleflight (não dispara load duplicado)
|
||||
* - loadSessionAndTenant: usuário não logado, com memberships, com saved
|
||||
* tenant válido/inválido, fallback ao primeiro active
|
||||
* - setActiveTenant: tenant válido vs inválido (regressão V#5: tenant_id
|
||||
* de outro usuário não pode ser herdado)
|
||||
* - reset: limpa state e localStorage
|
||||
* - getters: tenantId / currentTenantId / tenant / hasActiveTenant
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
// Stub mínimo de localStorage (vitest roda em env "node" sem jsdom).
|
||||
// Mantém Map em memória, mesmo contrato síncrono.
|
||||
const lsBacking = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (lsBacking.has(k) ? lsBacking.get(k) : null),
|
||||
setItem: (k, v) => lsBacking.set(k, String(v)),
|
||||
removeItem: (k) => lsBacking.delete(k),
|
||||
clear: () => lsBacking.clear(),
|
||||
key: (i) => Array.from(lsBacking.keys())[i] ?? null,
|
||||
get length() {
|
||||
return lsBacking.size;
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionMock = vi.fn();
|
||||
const rpcMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: {
|
||||
auth: { getSession: (...a) => getSessionMock(...a) },
|
||||
rpc: (...a) => rpcMock(...a)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/support/supportLogger', () => ({
|
||||
logTenant: vi.fn(),
|
||||
logError: vi.fn()
|
||||
}));
|
||||
|
||||
const { useTenantStore } = await import('../tenantStore.js');
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
getSessionMock.mockReset();
|
||||
rpcMock.mockReset();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const USER = { id: 'u-1', email: 'user@test.com' };
|
||||
const SESSION = { user: USER };
|
||||
|
||||
const memActive = (tenantId, role = 'tenant_admin', kind = 'clinic') => ({
|
||||
tenant_id: tenantId,
|
||||
role,
|
||||
kind,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
describe('loadSessionAndTenant — usuário não autenticado', () => {
|
||||
it('limpa estado quando não há sessão', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: null }, error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.loaded).toBe(true);
|
||||
expect(s.user).toBe(null);
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.activeRole).toBe(null);
|
||||
expect(s.memberships).toEqual([]);
|
||||
expect(s.needsTenantLink).toBe(false);
|
||||
// não chamou my_tenants sem user
|
||||
expect(rpcMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSessionAndTenant — usuário com memberships', () => {
|
||||
it('seleciona primeiro active quando não há saved', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe('t1');
|
||||
expect(s.activeRole).toBe('clinic_admin');
|
||||
expect(localStorage.getItem('tenant_id')).toBe('t1');
|
||||
expect(s.needsTenantLink).toBe(false);
|
||||
});
|
||||
|
||||
it('respeita saved tenant se pertencer ao usuário (membership active)', async () => {
|
||||
localStorage.setItem('tenant_id', 't2');
|
||||
localStorage.setItem('tenant', JSON.stringify({ id: 't2', role: 'clinic_admin' }));
|
||||
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe('t2');
|
||||
});
|
||||
|
||||
it('regressão V#5: descarta saved tenant que NÃO pertence ao usuário e cai no primeiro active', async () => {
|
||||
localStorage.setItem('tenant_id', 't-foreign');
|
||||
localStorage.setItem('tenant', JSON.stringify({ id: 't-foreign', role: 'clinic_admin' }));
|
||||
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe('t1');
|
||||
// localStorage foi atualizado pro tenant correto
|
||||
expect(localStorage.getItem('tenant_id')).toBe('t1');
|
||||
});
|
||||
|
||||
it('needsTenantLink=true quando usuário não tem nenhum membership active', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [{ ...memActive('t1'), status: 'pending' }], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.needsTenantLink).toBe(true);
|
||||
});
|
||||
|
||||
it('normaliza role via roleNormalizer (tenant_admin + kind=therapist → therapist)', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1', 'tenant_admin', 'therapist')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeRole).toBe('therapist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureLoaded — singleflight', () => {
|
||||
it('não recarrega se já loaded', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.ensureLoaded();
|
||||
await s.ensureLoaded();
|
||||
await s.ensureLoaded();
|
||||
|
||||
// só uma chamada de getSession
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('chamadas concorrentes compartilham a mesma promise', async () => {
|
||||
let resolveSession;
|
||||
const sessionPromise = new Promise((res) => {
|
||||
resolveSession = res;
|
||||
});
|
||||
getSessionMock.mockReturnValue(sessionPromise);
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
const p1 = s.ensureLoaded();
|
||||
const p2 = s.ensureLoaded();
|
||||
const p3 = s.ensureLoaded();
|
||||
|
||||
// resolve a única getSession
|
||||
resolveSession({ data: { session: SESSION }, error: null });
|
||||
await Promise.all([p1, p2, p3]);
|
||||
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(rpcMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSessionAndTenant — erros', () => {
|
||||
it('erro em getSession seta error e marca loaded', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: null, error: new Error('session fail') });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.error).toBeTruthy();
|
||||
expect(s.loaded).toBe(true);
|
||||
});
|
||||
|
||||
it('erro em my_tenants seta error e mantém estado pré-existente', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: null, error: new Error('rpc fail') });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.error).toBeTruthy();
|
||||
expect(s.user).toEqual(USER); // user já tinha sido setado antes do throw
|
||||
expect(s.loaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveTenant — troca explícita', () => {
|
||||
it('aceita tenant válido (membership active)', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2', 'tenant_admin', 'therapist')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
s.setActiveTenant('t2');
|
||||
|
||||
expect(s.activeTenantId).toBe('t2');
|
||||
expect(s.activeRole).toBe('therapist');
|
||||
expect(localStorage.getItem('tenant_id')).toBe('t2');
|
||||
});
|
||||
|
||||
it('rejeita tenant que não está nos memberships (sem mudar nada → null)', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
s.setActiveTenant('t-foreign');
|
||||
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.activeRole).toBe(null);
|
||||
expect(s.needsTenantLink).toBe(true);
|
||||
expect(localStorage.getItem('tenant_id')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset — limpeza completa', () => {
|
||||
it('zera estado e localStorage', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
s.reset();
|
||||
|
||||
expect(s.user).toBe(null);
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.activeRole).toBe(null);
|
||||
expect(s.memberships).toEqual([]);
|
||||
expect(s.loaded).toBe(false);
|
||||
expect(localStorage.getItem('tenant_id')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getters', () => {
|
||||
it('tenantId / currentTenantId / role expõem activeTenantId / activeRole', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.tenantId).toBe('t1');
|
||||
expect(s.currentTenantId).toBe('t1');
|
||||
expect(s.role).toBe('clinic_admin');
|
||||
expect(s.tenant).toEqual({ id: 't1', role: 'clinic_admin' });
|
||||
expect(s.hasActiveTenant).toBe(true);
|
||||
});
|
||||
|
||||
it('tenant=null e hasActiveTenant=false sem activeTenantId', () => {
|
||||
const s = useTenantStore();
|
||||
expect(s.tenant).toBe(null);
|
||||
expect(s.hasActiveTenant).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,21 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// entitlementsStore — features que o PLANO PERMITE (read-only, derivado).
|
||||
//
|
||||
// Separação V#41 (Opção B2):
|
||||
// entitlementsStore.has(key) = "o plano permite essa feature?"
|
||||
// tenantFeaturesStore.isEnabled(k) = "essa feature está ATIVA agora?"
|
||||
// (combina plano + override)
|
||||
//
|
||||
// Source: views v_tenant_entitlements (plano da clínica) e v_user_entitlements
|
||||
// (assinatura pessoal — solo therapist/supervisor). has() sem scope = união.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
function normalizeKey(k) {
|
||||
return String(k || '').trim();
|
||||
@@ -131,11 +144,12 @@ export const useEntitlementsStore = defineStore('entitlements', {
|
||||
this.loadedForTenant = tenantId;
|
||||
this.tenantLoadedAt = Date.now();
|
||||
} catch (e) {
|
||||
// V#42: NÃO marcar como carregado em caso de erro — força próximo
|
||||
// request a tentar de novo em vez de mascarar com array vazio.
|
||||
this.tenantError = e;
|
||||
this.tenantRaw = [];
|
||||
this.tenantFeatures = [];
|
||||
this.loadedForTenant = tenantId;
|
||||
this.tenantLoadedAt = Date.now();
|
||||
logError('entitlementsStore.loadForTenant', `tenantId=${tenantId}`, e);
|
||||
} finally {
|
||||
this.tenantLoading = false;
|
||||
}
|
||||
@@ -171,11 +185,11 @@ export const useEntitlementsStore = defineStore('entitlements', {
|
||||
this.loadedForUser = userId;
|
||||
this.userLoadedAt = Date.now();
|
||||
} catch (e) {
|
||||
// V#42: mesmo tratamento de loadForTenant — não marca como carregado.
|
||||
this.userError = e;
|
||||
this.userRaw = [];
|
||||
this.userFeatures = [];
|
||||
this.loadedForUser = userId;
|
||||
this.userLoadedAt = Date.now();
|
||||
logError('entitlementsStore.loadForUser', `userId=${userId}`, e);
|
||||
} finally {
|
||||
this.userLoading = false;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,28 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// tenantFeaturesStore — overrides por tenant ("o que está ATIVO agora?")
|
||||
//
|
||||
// Funciona em conjunto com entitlementsStore ("o que o plano PERMITE?").
|
||||
// V#34/V#41 — Opção B2:
|
||||
// feature ATIVA quando:
|
||||
// plano permite AND nenhum override negativo, OU
|
||||
// override positivo (exceção comercial liberada por saas_admin via RPC)
|
||||
//
|
||||
// Toda escrita passa pela RPC set_tenant_feature_exception (autorização +
|
||||
// log + bypass controlado do trigger guard).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
||||
|
||||
// V#18 — TTL configurável (default 5min). Força refetch se cache for mais
|
||||
// velho que TTL. Antes lastFetchedAt era registrado mas nunca verificado.
|
||||
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
const loading = ref(false);
|
||||
@@ -26,6 +45,9 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
// Cache por tenant: { [tenantId]: { [feature_key]: boolean } }
|
||||
const featuresByTenant = ref({});
|
||||
|
||||
// V#18 — timestamps por tenant pra TTL granular
|
||||
const fetchedAtByTenant = ref({});
|
||||
|
||||
// Marca o último tenant buscado (útil pra debug)
|
||||
const loadedForTenantId = ref(null);
|
||||
|
||||
@@ -34,15 +56,43 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
return featuresByTenant.value?.[tenantId] || {};
|
||||
}
|
||||
|
||||
// 🔎 Se você passar tenantId, lê desse tenant; se não, tenta o último carregado
|
||||
// Modelo opt-out: se a feature não está configurada (key ausente do mapa), retorna true por padrão.
|
||||
// Só retorna false quando explicitamente desabilitada no banco.
|
||||
/**
|
||||
* isEnabled — feature está ATIVA para o tenant?
|
||||
*
|
||||
* Combina plano (entitlementsStore) + override (tenant_features):
|
||||
* - override negativo (false) → INATIVO sempre
|
||||
* - override positivo (true) → ATIVO sempre (exceção comercial)
|
||||
* - sem override → segue plano
|
||||
*
|
||||
* Pré-condição: entitlementsStore deve estar carregado para o mesmo
|
||||
* tenant (loadForTenant) ou para o user (loadForUser, fallback p/ solo
|
||||
* therapist). Se não estiver, retorna false (fail-safe).
|
||||
*/
|
||||
function isEnabled(key, tenantId = null) {
|
||||
const tid = tenantId || loadedForTenantId.value;
|
||||
if (!tid) return false;
|
||||
if (!tid || !key) return false;
|
||||
|
||||
const map = getTenantMap(tid);
|
||||
if (!(key in map)) return true; // não configurada = habilitada por padrão
|
||||
return !!map[key];
|
||||
|
||||
// Override explícito tem precedência
|
||||
if (key in map) {
|
||||
return !!map[key];
|
||||
}
|
||||
|
||||
// Sem override → consulta plano
|
||||
const ent = useEntitlementsStore();
|
||||
|
||||
// Tenant entitlements têm prioridade quando carregado para esse tenant
|
||||
if (ent.loadedForTenant === tid && Array.isArray(ent.tenantFeatures) && ent.tenantFeatures.includes(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: assinatura pessoal (solo therapist)
|
||||
if (Array.isArray(ent.userFeatures) && ent.userFeatures.includes(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function invalidate(tenantId = null) {
|
||||
@@ -50,20 +100,29 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
if (!tenantId) {
|
||||
loadedForTenantId.value = null;
|
||||
featuresByTenant.value = {};
|
||||
fetchedAtByTenant.value = {};
|
||||
return;
|
||||
}
|
||||
// invalida apenas um tenant
|
||||
const copy = { ...featuresByTenant.value };
|
||||
delete copy[tenantId];
|
||||
featuresByTenant.value = copy;
|
||||
const tCopy = { ...fetchedAtByTenant.value };
|
||||
delete tCopy[tenantId];
|
||||
fetchedAtByTenant.value = tCopy;
|
||||
if (loadedForTenantId.value === tenantId) loadedForTenantId.value = null;
|
||||
}
|
||||
|
||||
async function fetchForTenant(tenantId, { force = false } = {}) {
|
||||
async function fetchForTenant(tenantId, { force = false, maxAgeMs = DEFAULT_TTL_MS } = {}) {
|
||||
if (!tenantId) return;
|
||||
|
||||
// se já tem cache e não é force, não busca de novo
|
||||
if (!force && featuresByTenant.value?.[tenantId]) {
|
||||
// V#18 — usa TTL real: cache válido apenas se age < maxAgeMs
|
||||
const cached = featuresByTenant.value?.[tenantId];
|
||||
const fetchedAt = fetchedAtByTenant.value?.[tenantId] || 0;
|
||||
const age = Date.now() - fetchedAt;
|
||||
const cacheValid = !force && cached && (maxAgeMs === 0 || age < maxAgeMs);
|
||||
|
||||
if (cacheValid) {
|
||||
loadedForTenantId.value = tenantId;
|
||||
return;
|
||||
}
|
||||
@@ -83,6 +142,10 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: map
|
||||
};
|
||||
fetchedAtByTenant.value = {
|
||||
...fetchedAtByTenant.value,
|
||||
[tenantId]: Date.now()
|
||||
};
|
||||
|
||||
loadedForTenantId.value = tenantId;
|
||||
lastFetchedAt.value = new Date().toISOString();
|
||||
@@ -96,27 +159,39 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function setForTenant(tenantId, key, enabled) {
|
||||
/**
|
||||
* setForTenant — agora roteia tudo via RPC set_tenant_feature_exception.
|
||||
* Autorização e validação ficam no servidor (V#34 Opção B2):
|
||||
* - tenant_admin pode desligar feature do plano e religar
|
||||
* - saas_admin pode tudo + override positivo fora do plano (reason obrigatório)
|
||||
*/
|
||||
async function setForTenant(tenantId, key, enabled, { reason = null } = {}) {
|
||||
if (!tenantId) throw new Error('tenantId missing');
|
||||
if (!key) throw new Error('feature_key missing');
|
||||
|
||||
lastError.value = null;
|
||||
|
||||
const payload = { tenant_id: tenantId, feature_key: key, enabled: !!enabled };
|
||||
|
||||
const { error } = await supabase.from('tenant_features').upsert(payload, { onConflict: 'tenant_id,feature_key' });
|
||||
const { data, error } = await supabase.rpc('set_tenant_feature_exception', {
|
||||
p_tenant_id: tenantId,
|
||||
p_feature_key: key,
|
||||
p_enabled: !!enabled,
|
||||
p_reason: reason
|
||||
});
|
||||
|
||||
if (error) {
|
||||
lastError.value = error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Atualiza cache local do tenant (mesmo que ainda não tenha sido carregado)
|
||||
// Atualiza cache local do tenant
|
||||
const current = getTenantMap(tenantId);
|
||||
featuresByTenant.value = {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: { ...current, [key]: !!enabled }
|
||||
};
|
||||
loadedForTenantId.value = tenantId;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// (opcional) útil pra debug rápido na tela
|
||||
|
||||
+23
-34
@@ -16,30 +16,12 @@
|
||||
*/
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { normalizeRole } from '@/utils/roleNormalizer';
|
||||
import { logTenant, logError } from '@/support/supportLogger';
|
||||
|
||||
/**
|
||||
* Normaliza o role de tenant levando em conta o kind do tenant.
|
||||
*
|
||||
* Regras:
|
||||
* tenant_admin / admin + kind = 'therapist' → 'therapist'
|
||||
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (padrão legado)
|
||||
* qualquer outro role → pass-through
|
||||
*/
|
||||
// wrapper que mantém o contrato original do tenantStore (retornar null quando role vazio)
|
||||
function normalizeTenantRole(role, kind) {
|
||||
const r = String(role || '').trim();
|
||||
if (!r) return null;
|
||||
|
||||
const isAdmin = r === 'tenant_admin' || r === 'admin';
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim();
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist';
|
||||
if (k === 'supervisor') return 'supervisor';
|
||||
return 'clinic_admin';
|
||||
}
|
||||
|
||||
return r;
|
||||
return normalizeRole(role, kind) || null;
|
||||
}
|
||||
|
||||
function readSavedTenant() {
|
||||
@@ -65,6 +47,10 @@ function clearPersistedTenant() {
|
||||
localStorage.removeItem('tenant');
|
||||
}
|
||||
|
||||
// Promise compartilhada: enquanto um loadSessionAndTenant está em voo, todos os
|
||||
// callers concorrentes aguardam a mesma promise (singleflight).
|
||||
let loadPromise = null;
|
||||
|
||||
export const useTenantStore = defineStore('tenant', {
|
||||
state: () => ({
|
||||
loading: false,
|
||||
@@ -88,22 +74,25 @@ export const useTenantStore = defineStore('tenant', {
|
||||
actions: {
|
||||
async ensureLoaded() {
|
||||
if (this.loaded) return;
|
||||
if (this.loading) {
|
||||
await new Promise((resolve) => {
|
||||
const t = setInterval(() => {
|
||||
if (!this.loading) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
if (loadPromise) {
|
||||
await loadPromise;
|
||||
return;
|
||||
}
|
||||
await this.loadSessionAndTenant();
|
||||
},
|
||||
|
||||
async loadSessionAndTenant() {
|
||||
if (this.loading) return;
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = this._doLoadSessionAndTenant();
|
||||
try {
|
||||
await loadPromise;
|
||||
} finally {
|
||||
loadPromise = null;
|
||||
}
|
||||
},
|
||||
|
||||
async _doLoadSessionAndTenant() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -139,7 +128,7 @@ export const useTenantStore = defineStore('tenant', {
|
||||
if (savedTenantId) {
|
||||
activeMembership = this.memberships.find((x) => x.tenant_id === savedTenantId && x.status === 'active');
|
||||
if (!activeMembership) {
|
||||
console.warn('[tenantStore] tenant salvo não pertence a este usuário, limpando.');
|
||||
logTenant('tenantStore', 'tenant salvo não pertence a este usuário, limpando', { savedTenantId });
|
||||
clearPersistedTenant();
|
||||
}
|
||||
}
|
||||
@@ -161,7 +150,7 @@ export const useTenantStore = defineStore('tenant', {
|
||||
this.needsTenantLink = !this.activeTenantId;
|
||||
this.loaded = true;
|
||||
} catch (e) {
|
||||
console.warn('[tenantStore] loadSessionAndTenant falhou:', e);
|
||||
logError('tenantStore', 'loadSessionAndTenant falhou', e);
|
||||
this.error = e;
|
||||
|
||||
if (!this.user) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* roleNormalizer.spec.js
|
||||
*
|
||||
* Função pura — zero mock. Garante contrato único entre guards.js e
|
||||
* tenantStore.js (evita regressão do V#4 resolvido).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeRole } from '../roleNormalizer.js';
|
||||
|
||||
describe('normalizeRole — tenant_admin/admin + kind', () => {
|
||||
it('tenant_admin + kind=therapist → therapist', () => {
|
||||
expect(normalizeRole('tenant_admin', 'therapist')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('tenant_admin + kind=saas → therapist (saas é alias de therapist)', () => {
|
||||
expect(normalizeRole('tenant_admin', 'saas')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('tenant_admin + kind=supervisor → supervisor', () => {
|
||||
expect(normalizeRole('tenant_admin', 'supervisor')).toBe('supervisor');
|
||||
});
|
||||
|
||||
it('tenant_admin + kind=clinic → clinic_admin (fallback)', () => {
|
||||
expect(normalizeRole('tenant_admin', 'clinic')).toBe('clinic_admin');
|
||||
});
|
||||
|
||||
it('tenant_admin sem kind → clinic_admin (legado)', () => {
|
||||
expect(normalizeRole('tenant_admin', null)).toBe('clinic_admin');
|
||||
expect(normalizeRole('tenant_admin', '')).toBe('clinic_admin');
|
||||
expect(normalizeRole('tenant_admin')).toBe('clinic_admin');
|
||||
});
|
||||
|
||||
it('admin (alias legado) segue mesmas regras de tenant_admin', () => {
|
||||
expect(normalizeRole('admin', 'therapist')).toBe('therapist');
|
||||
expect(normalizeRole('admin', 'supervisor')).toBe('supervisor');
|
||||
expect(normalizeRole('admin', null)).toBe('clinic_admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRole — pass-through', () => {
|
||||
it('clinic_admin canônico → clinic_admin', () => {
|
||||
expect(normalizeRole('clinic_admin')).toBe('clinic_admin');
|
||||
expect(normalizeRole('clinic_admin', 'qualquer')).toBe('clinic_admin');
|
||||
});
|
||||
|
||||
it('therapist explícito → therapist', () => {
|
||||
expect(normalizeRole('therapist')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('supervisor explícito → supervisor', () => {
|
||||
expect(normalizeRole('supervisor')).toBe('supervisor');
|
||||
});
|
||||
|
||||
it('roles não mapeados retornam intactos', () => {
|
||||
expect(normalizeRole('saas_admin')).toBe('saas_admin');
|
||||
expect(normalizeRole('portal_user')).toBe('portal_user');
|
||||
expect(normalizeRole('editor')).toBe('editor');
|
||||
expect(normalizeRole('patient')).toBe('patient');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRole — entradas inválidas', () => {
|
||||
it('null/undefined retornam string vazia', () => {
|
||||
expect(normalizeRole(null)).toBe('');
|
||||
expect(normalizeRole(undefined)).toBe('');
|
||||
expect(normalizeRole()).toBe('');
|
||||
});
|
||||
|
||||
it('string vazia retorna vazia', () => {
|
||||
expect(normalizeRole('')).toBe('');
|
||||
expect(normalizeRole(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('trim é aplicado', () => {
|
||||
expect(normalizeRole(' therapist ')).toBe('therapist');
|
||||
expect(normalizeRole(' tenant_admin ', ' therapist ')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('números são coercidos para string', () => {
|
||||
// Não deveria acontecer em produção, mas não quebra
|
||||
expect(normalizeRole(123)).toBe('123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* validators.spec.js — T#7 (cobertura dos helpers de sanitização do external intake)
|
||||
*
|
||||
* O módulo validators.js é a base de toda sanitização da página pública de
|
||||
* cadastro (CadastroPacienteExterno) e de várias outras páginas internas.
|
||||
* Esses testes garantem o contrato: o backend (RPC create_patient_intake_request_v2)
|
||||
* confia que os dígitos vêm normalizados, datas em ISO, emails sem espaço, etc.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
digitsOnly,
|
||||
isValidCPF,
|
||||
fmtCPF,
|
||||
generateCPF,
|
||||
isValidCNPJ,
|
||||
fmtCNPJ,
|
||||
fmtRG,
|
||||
isValidPhone,
|
||||
fmtPhone,
|
||||
isValidEmail,
|
||||
isValidCEP,
|
||||
fmtCEP,
|
||||
sanitizeDigits,
|
||||
toISODate
|
||||
} from '../validators.js';
|
||||
|
||||
describe('digitsOnly — base de toda sanitização', () => {
|
||||
it('extrai só dígitos de strings com máscara', () => {
|
||||
expect(digitsOnly('(11) 98765-4321')).toBe('11987654321');
|
||||
expect(digitsOnly('123.456.789-01')).toBe('12345678901');
|
||||
expect(digitsOnly('00000-000')).toBe('00000000');
|
||||
});
|
||||
|
||||
it('retorna string vazia para null/undefined/vazio', () => {
|
||||
expect(digitsOnly(null)).toBe('');
|
||||
expect(digitsOnly(undefined)).toBe('');
|
||||
expect(digitsOnly('')).toBe('');
|
||||
expect(digitsOnly(' ')).toBe('');
|
||||
expect(digitsOnly('abc')).toBe('');
|
||||
});
|
||||
|
||||
it('preserva apenas os dígitos em qualquer string', () => {
|
||||
expect(digitsOnly('a1b2c3')).toBe('123');
|
||||
expect(digitsOnly('R$ 1.234,56')).toBe('123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCPF — algoritmo completo', () => {
|
||||
it('aceita CPFs válidos com e sem máscara', () => {
|
||||
expect(isValidCPF('529.982.247-25')).toBe(true);
|
||||
expect(isValidCPF('52998224725')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita comprimento incorreto', () => {
|
||||
expect(isValidCPF('123')).toBe(false);
|
||||
expect(isValidCPF('12345678901234')).toBe(false);
|
||||
expect(isValidCPF('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita sequências repetidas (regressão fraude clássica)', () => {
|
||||
expect(isValidCPF('11111111111')).toBe(false);
|
||||
expect(isValidCPF('00000000000')).toBe(false);
|
||||
expect(isValidCPF('99999999999')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita dígitos verificadores inválidos', () => {
|
||||
expect(isValidCPF('52998224726')).toBe(false); // último dígito errado
|
||||
expect(isValidCPF('52998224715')).toBe(false); // penúltimo dígito errado
|
||||
});
|
||||
|
||||
it('aceita CPF gerado por generateCPF (round-trip)', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const gen = generateCPF();
|
||||
expect(isValidCPF(gen)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtCPF — formatação visual', () => {
|
||||
it('formata 11 dígitos como 000.000.000-00', () => {
|
||||
expect(fmtCPF('52998224725')).toBe('529.982.247-25');
|
||||
});
|
||||
|
||||
it('aceita parcial e formata até onde tem', () => {
|
||||
expect(fmtCPF('123')).toBe('123');
|
||||
expect(fmtCPF('1234')).toBe('123.4');
|
||||
expect(fmtCPF('123456')).toBe('123.456');
|
||||
expect(fmtCPF('1234567')).toBe('123.456.7');
|
||||
});
|
||||
|
||||
it('trunca em 11 dígitos', () => {
|
||||
expect(fmtCPF('5299822472512345')).toBe('529.982.247-25');
|
||||
});
|
||||
|
||||
it('vazio para null/empty', () => {
|
||||
expect(fmtCPF('')).toBe('');
|
||||
expect(fmtCPF(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCNPJ — algoritmo completo', () => {
|
||||
it('aceita CNPJs válidos com e sem máscara', () => {
|
||||
expect(isValidCNPJ('11.222.333/0001-81')).toBe(true);
|
||||
expect(isValidCNPJ('11222333000181')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita sequências repetidas', () => {
|
||||
expect(isValidCNPJ('11111111111111')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita comprimento incorreto', () => {
|
||||
expect(isValidCNPJ('123')).toBe(false);
|
||||
expect(isValidCNPJ('11.222.333/0001-')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtCNPJ — formatação visual', () => {
|
||||
it('formata 14 dígitos como 00.000.000/0000-00', () => {
|
||||
expect(fmtCNPJ('11222333000181')).toBe('11.222.333/0001-81');
|
||||
});
|
||||
|
||||
it('vazio para input vazio', () => {
|
||||
expect(fmtCNPJ('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtRG — formatação visual', () => {
|
||||
it('formata 9 dígitos', () => {
|
||||
expect(fmtRG('123456789')).toBe('12.345.678-9');
|
||||
});
|
||||
|
||||
it('vazio para vazio/null', () => {
|
||||
expect(fmtRG('')).toBe('');
|
||||
expect(fmtRG(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidPhone — telefones BR', () => {
|
||||
it('aceita 10 dígitos (fixo) e 11 (celular)', () => {
|
||||
expect(isValidPhone('1133334444')).toBe(true); // fixo
|
||||
expect(isValidPhone('11933334444')).toBe(true); // celular
|
||||
expect(isValidPhone('(11) 9 3333-4444')).toBe(true); // com máscara
|
||||
});
|
||||
|
||||
it('rejeita comprimentos errados', () => {
|
||||
expect(isValidPhone('123')).toBe(false);
|
||||
expect(isValidPhone('123456789012')).toBe(false);
|
||||
expect(isValidPhone('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtPhone — formatação visual', () => {
|
||||
it('formata celular (11 dígitos) como (XX) XXXXX-XXXX', () => {
|
||||
expect(fmtPhone('11987654321')).toBe('(11) 98765-4321');
|
||||
});
|
||||
|
||||
it('formata fixo (10 dígitos) como (XX) XXXX-XXXX', () => {
|
||||
expect(fmtPhone('1133334444')).toBe('(11) 3333-4444');
|
||||
});
|
||||
|
||||
it('retorna dígitos crus para tamanhos não suportados', () => {
|
||||
expect(fmtPhone('123')).toBe('123');
|
||||
});
|
||||
|
||||
it('vazio para vazio', () => {
|
||||
expect(fmtPhone('')).toBe('');
|
||||
expect(fmtPhone(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmail — formato mínimo', () => {
|
||||
it('aceita emails com formato razoável', () => {
|
||||
expect(isValidEmail('user@test.com')).toBe(true);
|
||||
expect(isValidEmail('a@b.c')).toBe(true);
|
||||
expect(isValidEmail(' user@test.com ')).toBe(true); // trim aplicado
|
||||
});
|
||||
|
||||
it('rejeita vazios e null', () => {
|
||||
expect(isValidEmail('')).toBe(false);
|
||||
expect(isValidEmail(null)).toBe(false);
|
||||
expect(isValidEmail(undefined)).toBe(false);
|
||||
expect(isValidEmail(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita formatos sem @ ou sem domínio', () => {
|
||||
expect(isValidEmail('user')).toBe(false);
|
||||
expect(isValidEmail('user@')).toBe(false);
|
||||
expect(isValidEmail('@host.com')).toBe(false);
|
||||
expect(isValidEmail('user@host')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCEP — 8 dígitos', () => {
|
||||
it('aceita CEP de 8 dígitos com ou sem máscara', () => {
|
||||
expect(isValidCEP('01310-100')).toBe(true);
|
||||
expect(isValidCEP('01310100')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita comprimentos errados', () => {
|
||||
expect(isValidCEP('123')).toBe(false);
|
||||
expect(isValidCEP('12345-67')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtCEP — formatação visual', () => {
|
||||
it('formata 8 dígitos como 00000-000', () => {
|
||||
expect(fmtCEP('01310100')).toBe('01310-100');
|
||||
});
|
||||
|
||||
it('parcial mantém prefixo formatado', () => {
|
||||
expect(fmtCEP('01310')).toBe('01310');
|
||||
expect(fmtCEP('013101')).toBe('01310-1');
|
||||
});
|
||||
|
||||
it('trunca em 8', () => {
|
||||
expect(fmtCEP('0131010012345')).toBe('01310-100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeDigits — preparação para o banco', () => {
|
||||
it('extrai dígitos ou retorna null para vazio', () => {
|
||||
expect(sanitizeDigits('(11) 98765-4321')).toBe('11987654321');
|
||||
expect(sanitizeDigits('')).toBe(null);
|
||||
expect(sanitizeDigits(null)).toBe(null);
|
||||
expect(sanitizeDigits('abc')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toISODate — DD/MM/YYYY ou DD-MM-YYYY → YYYY-MM-DD', () => {
|
||||
it('converte DD/MM/YYYY → ISO', () => {
|
||||
expect(toISODate('15/03/1990')).toBe('1990-03-15');
|
||||
});
|
||||
|
||||
it('converte DD-MM-YYYY → ISO', () => {
|
||||
expect(toISODate('15-03-1990')).toBe('1990-03-15');
|
||||
});
|
||||
|
||||
it('retorna null para formato inválido', () => {
|
||||
expect(toISODate('1990-03-15')).toBe(null); // já em ISO, mas formato esperado é DD/MM/YYYY
|
||||
expect(toISODate('15/3/1990')).toBe(null); // mês com 1 dígito
|
||||
expect(toISODate('abc')).toBe(null);
|
||||
expect(toISODate('')).toBe(null);
|
||||
expect(toISODate(null)).toBe(null);
|
||||
});
|
||||
|
||||
it('rejeita data inexistente (mes 13)', () => {
|
||||
// Date constructor faz wrap silencioso; testamos comportamento atual.
|
||||
// 13/13/2020 → Date('2020-13-13') é Invalid Date → null
|
||||
expect(toISODate('13/13/2020')).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/utils/roleNormalizer.js
|
||||
|
|
||||
| Normalização única de roles de tenant. Antes existia em guards.js e
|
||||
| tenantStore.js duplicada — se uma mudasse sem a outra, tenant e guard
|
||||
| divergiam sobre o role normalizado (bug sutil de permissão).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normaliza o role a partir do role cru do tenant_members + kind do tenant.
|
||||
*
|
||||
* Regras:
|
||||
* tenant_admin / admin + kind = 'therapist' | 'saas' → 'therapist'
|
||||
* tenant_admin / admin + kind = 'supervisor' → 'supervisor'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
|
||||
* 'clinic_admin' (role canônico) → 'clinic_admin'
|
||||
* qualquer outro role → pass-through
|
||||
*
|
||||
* @param {string|null|undefined} role Role cru (tenant_members.role ou profiles.role).
|
||||
* @param {string|null|undefined} kind Kind do tenant (tenants.kind).
|
||||
* @returns {string} role normalizado (sempre string — '' quando sem input).
|
||||
*/
|
||||
export function normalizeRole(role, kind) {
|
||||
const r = String(role || '').trim();
|
||||
if (!r) return '';
|
||||
|
||||
const isAdmin = r === 'tenant_admin' || r === 'admin';
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim();
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist';
|
||||
if (k === 'supervisor') return 'supervisor';
|
||||
return 'clinic_admin';
|
||||
}
|
||||
|
||||
if (r === 'clinic_admin') return 'clinic_admin';
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -714,7 +714,9 @@ onMounted(async () => {
|
||||
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<div class="flex items-center justify-between mb-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-lg" />
|
||||
</div>
|
||||
<div class="flex flex-col leading-tight">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
||||
@@ -788,7 +790,9 @@ onMounted(async () => {
|
||||
<!-- Agendamentos Recebidos -->
|
||||
<div v-if="!loading" class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-inbox w-10 h-10 rounded-md cfg-subheader__icon" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-inbox text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Agendamentos Recebidos</div>
|
||||
<div class="dash-card__sub">Solicitações vindas do agendador online</div>
|
||||
@@ -821,7 +825,9 @@ onMounted(async () => {
|
||||
<!-- Cadastros externos -->
|
||||
<div v-if="!loading" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-user-plus w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9">
|
||||
<i class="pi pi-user-plus text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Cadastros Externos</div>
|
||||
<div class="dash-card__sub">Pacientes que preencheram seus próprios dados</div>
|
||||
@@ -854,7 +860,9 @@ onMounted(async () => {
|
||||
<!-- Ocupação dos terapeutas -->
|
||||
<div v-if="!loading" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-users w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #8b5cf6 15%, transparent); color: #8b5cf6" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #8b5cf6 15%, transparent); color: #8b5cf6">
|
||||
<i class="pi pi-users text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Ocupação dos Terapeutas</div>
|
||||
<div class="dash-card__sub">Sessões este mês por profissional</div>
|
||||
@@ -890,7 +898,9 @@ onMounted(async () => {
|
||||
<!-- Radar da semana -->
|
||||
<div v-if="!loading" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-chart-pie w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1">
|
||||
<i class="pi pi-chart-pie text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Radar da Semana</div>
|
||||
<div class="dash-card__sub">Sessões e faltas da clínica</div>
|
||||
|
||||
@@ -72,8 +72,11 @@ function labelOf(key) {
|
||||
}
|
||||
|
||||
function isPlanDeniedError(e) {
|
||||
const msg = String(e?.message || e || '');
|
||||
return msg.toLowerCase().includes('não permitida') && msg.toLowerCase().includes('plano');
|
||||
const msg = String(e?.message || e || '').toLowerCase();
|
||||
if (msg.includes('não permitida') && msg.includes('plano')) return true;
|
||||
if (msg.includes('fora do plano')) return true;
|
||||
if (msg.includes('saas_admin')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function markPlanDenied(key, e) {
|
||||
@@ -426,7 +429,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('patients')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
@@ -451,7 +454,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('shared_reception')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
@@ -476,7 +479,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('rooms')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.</div>
|
||||
@@ -494,7 +497,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('intake_public')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Você já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import Textarea from 'primevue/textarea';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Message from 'primevue/message';
|
||||
import Popover from 'primevue/popover';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
@@ -32,15 +33,32 @@ 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';
|
||||
import MathCaptchaChallenge from '@/components/security/MathCaptchaChallenge.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
// A#20 rev2 — defesa em camadas self-hosted
|
||||
// Honeypot: campo invisível que humano nunca preenche
|
||||
const honeypot = ref('');
|
||||
// Math captcha: ativado sob demanda quando edge function devolve 403 captcha-required
|
||||
const captchaRequired = ref(false);
|
||||
const captchaId = ref('');
|
||||
const captchaAnswer = ref(null);
|
||||
|
||||
const salvando = ref(false);
|
||||
const sucesso = ref(false);
|
||||
|
||||
// A#25: dialog de política de privacidade (LGPD)
|
||||
const policyOpen = ref(false);
|
||||
|
||||
const token = computed(() => String(route.query.t || '').trim());
|
||||
const tokenOk = computed(() => token.value.length >= 10);
|
||||
// A#21: validação mais estrita — aceita UUID (com ou sem hífen, 32 hex chars).
|
||||
// Ainda é cosmético (servidor valida de verdade), mas reduz falsos "verificado".
|
||||
const TOKEN_RX = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const tokenOk = computed(() => TOKEN_RX.test(token.value));
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Helpers (✅ ficam ANTES do enviar())
|
||||
@@ -134,7 +152,6 @@ function preencherMock() {
|
||||
form.rg = generateRG();
|
||||
|
||||
form.observacoes = maybe(0.5) ? 'Cadastro realizado via link externo.' : 'Tenho disponibilidade no período da noite.';
|
||||
form.notas_internas = maybe(0.5) ? 'Paciente demonstrou interesse em iniciar nas próximas semanas.' : '';
|
||||
|
||||
form.onde_nos_conheceu = pick(['Instagram', 'Google', 'Indicação', 'Site', 'Threads', 'Outro']);
|
||||
form.encaminhado_por = maybe(0.45) ? `${pick(first)} ${pick(last)}` : '';
|
||||
@@ -167,63 +184,9 @@ function preencherMock() {
|
||||
toast.add({ severity: 'info', summary: 'Exemplo', detail: 'Campos preenchidos com dados simulados.', life: 1800 });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Foto (upload opcional)
|
||||
// ------------------------------------------------------
|
||||
const fotoFile = ref(null);
|
||||
const fotoPreviewUrl = ref('');
|
||||
const fotoErro = ref('');
|
||||
|
||||
function limparFoto() {
|
||||
fotoFile.value = null;
|
||||
fotoErro.value = '';
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
fotoPreviewUrl.value = '';
|
||||
form.foto_url = '';
|
||||
}
|
||||
|
||||
function onFotoPicked(evt) {
|
||||
fotoErro.value = '';
|
||||
const file = evt?.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type?.startsWith('image/')) {
|
||||
fotoErro.value = 'Arquivo inválido. Envie uma imagem.';
|
||||
return;
|
||||
}
|
||||
const maxMb = 8;
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
fotoErro.value = `Imagem muito grande. Máx: ${maxMb}MB.`;
|
||||
return;
|
||||
}
|
||||
|
||||
fotoFile.value = file;
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
fotoPreviewUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
const AVATAR_BUCKET = 'avatars';
|
||||
|
||||
async function uploadFotoSeHouver() {
|
||||
if (!fotoFile.value) return null;
|
||||
|
||||
const ext = (fotoFile.value.name.split('.').pop() || 'jpg').toLowerCase();
|
||||
const filePath = `intakes/${token.value}/${Date.now()}.${ext}`;
|
||||
|
||||
try {
|
||||
const { error: upErr } = await supabase.storage.from(AVATAR_BUCKET).upload(filePath, fotoFile.value, {
|
||||
upsert: true,
|
||||
contentType: fotoFile.value.type
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(filePath);
|
||||
return pub?.publicUrl || null;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Foto', detail: `Upload falhou: ${e?.message || 'erro desconhecido'}`, life: 4500 });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Upload de foto removido da página pública (A#15):
|
||||
// O bucket não aceita mais upload anônimo. O terapeuta pode pedir a foto
|
||||
// depois, por outro canal, se for necessário.
|
||||
|
||||
// ------------------------------------------------------
|
||||
// CEP (ViaCEP)
|
||||
@@ -299,8 +262,7 @@ 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-tags' },
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
|
||||
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' }
|
||||
];
|
||||
|
||||
const navPopover = ref(null);
|
||||
@@ -333,7 +295,6 @@ onBeforeUnmount(() => {
|
||||
if (mql.removeEventListener) mql.removeEventListener('change', mqlHandler);
|
||||
else mql.removeListener(mqlHandler);
|
||||
}
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -378,7 +339,6 @@ function resetForm() {
|
||||
onde_nos_conheceu: '',
|
||||
encaminhado_por: '',
|
||||
observacoes: '',
|
||||
notas_internas: '',
|
||||
nacionalidade: '',
|
||||
|
||||
email_alternativo: '',
|
||||
@@ -396,9 +356,7 @@ function resetForm() {
|
||||
telefone_responsavel: '',
|
||||
cpf_responsavel: '',
|
||||
observacao_responsavel: '',
|
||||
cobranca_no_responsavel: false,
|
||||
|
||||
foto_url: ''
|
||||
cobranca_no_responsavel: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,53 +421,53 @@ async function enviar() {
|
||||
|
||||
salvando.value = true;
|
||||
try {
|
||||
const fotoUrl = await uploadFotoSeHouver();
|
||||
if (fotoUrl) form.foto_url = fotoUrl;
|
||||
// A#17: notas_internas NÃO é enviado (campo interno do terapeuta, não
|
||||
// deve vir do paciente). A#15: avatar_url removido (upload não é mais
|
||||
// possível anonimamente). Todos os campos texto são recortados em
|
||||
// length máximo client-side (defesa em camadas — o RPC também valida).
|
||||
const truncate = (v, n) => {
|
||||
const s = cleanStr(v);
|
||||
return s ? String(s).slice(0, n) : null;
|
||||
};
|
||||
|
||||
const payload = {
|
||||
// essenciais
|
||||
nome_completo: cleanStr(form.nome_completo),
|
||||
email_principal: cleanStr(form.email_principal)?.toLowerCase() || null,
|
||||
nome_completo: truncate(form.nome_completo, 200),
|
||||
email_principal: cleanStr(form.email_principal)?.toLowerCase().slice(0, 120) || null,
|
||||
telefone: digitsOnly(form.telefone),
|
||||
|
||||
avatar_url: form.foto_url || null,
|
||||
|
||||
// alternativos
|
||||
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase() || null,
|
||||
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase().slice(0, 120) || null,
|
||||
telefone_alternativo: digitsOnly(form.telefone_alternativo) || null,
|
||||
|
||||
// docs / endereço
|
||||
cpf: digitsOnly(form.cpf),
|
||||
rg: cleanStr(form.rg),
|
||||
rg: truncate(form.rg, 20),
|
||||
cep: digitsOnly(form.cep),
|
||||
|
||||
pais: cleanStr(form.pais) || 'Brasil',
|
||||
cidade: cleanStr(form.cidade),
|
||||
estado: cleanStr(form.estado) || 'SP',
|
||||
endereco: cleanStr(form.endereco),
|
||||
numero: cleanStr(form.numero),
|
||||
bairro: cleanStr(form.bairro),
|
||||
complemento: cleanStr(form.complemento),
|
||||
pais: truncate(form.pais, 60) || 'Brasil',
|
||||
cidade: truncate(form.cidade, 120),
|
||||
estado: truncate(form.estado, 2) || 'SP',
|
||||
endereco: truncate(form.endereco, 200),
|
||||
numero: truncate(form.numero, 20),
|
||||
bairro: truncate(form.bairro, 120),
|
||||
complemento: truncate(form.complemento, 120),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: isoBirth || null,
|
||||
naturalidade: cleanStr(form.naturalidade),
|
||||
naturalidade: truncate(form.naturalidade, 120),
|
||||
genero: cleanStr(form.genero),
|
||||
estado_civil: cleanStr(form.estado_civil),
|
||||
|
||||
// adicionais (existem na tabela!)
|
||||
profissao: cleanStr(form.profissao),
|
||||
escolaridade: cleanStr(form.escolaridade),
|
||||
nacionalidade: cleanStr(form.nacionalidade),
|
||||
// adicionais
|
||||
profissao: truncate(form.profissao, 120),
|
||||
escolaridade: truncate(form.escolaridade, 120),
|
||||
nacionalidade: truncate(form.nacionalidade, 80),
|
||||
|
||||
// origem + texto
|
||||
onde_nos_conheceu: cleanStr(form.onde_nos_conheceu),
|
||||
encaminhado_por: cleanStr(form.encaminhado_por),
|
||||
observacoes: cleanStr(form.observacoes),
|
||||
|
||||
// ⚠️ eu recomendo NÃO enviar isso no externo,
|
||||
// mas a coluna existe — então deixo opcional:
|
||||
notas_internas: cleanStr(form.notas_internas),
|
||||
// origem + observações
|
||||
onde_nos_conheceu: truncate(form.onde_nos_conheceu, 80),
|
||||
encaminhado_por: truncate(form.encaminhado_por, 120),
|
||||
observacoes: truncate(form.observacoes, 2000),
|
||||
|
||||
// consent
|
||||
consent: !!consent.value
|
||||
@@ -519,28 +477,68 @@ async function enviar() {
|
||||
if (payload[k] === undefined) delete payload[k];
|
||||
});
|
||||
|
||||
let { error } = await supabase.rpc('create_patient_intake_request_v2', {
|
||||
p_token: token.value,
|
||||
p_payload: payload
|
||||
});
|
||||
// A#24: envia user_agent pra log de tentativas (sem PII).
|
||||
const clientInfo = (typeof navigator !== 'undefined' && navigator.userAgent)
|
||||
? String(navigator.userAgent).slice(0, 500)
|
||||
: null;
|
||||
|
||||
const isFnMissing = error && (error.code === '42883' || /create_patient_intake_request_v2/i.test(error.message));
|
||||
|
||||
const isOldSchemaColumn = error && (/column .* of relation .*patient_intake_requests.* does not exist/i.test(error.message) || /column "name" of relation "patient_intake_requests" does not exist/i.test(error.message));
|
||||
|
||||
if (isFnMissing || isOldSchemaColumn) {
|
||||
const { error: e2 } = await supabase.rpc('create_patient_intake_request', {
|
||||
p_token: token.value,
|
||||
p_name: payload.nome_completo,
|
||||
p_email: payload.email_principal,
|
||||
p_phone: payload.telefone,
|
||||
p_notes: payload.observacoes || null,
|
||||
p_consent: !!payload.consent
|
||||
});
|
||||
error = e2;
|
||||
// A#20 rev2: se já está exigindo captcha mas usuário não respondeu, bloqueia local
|
||||
if (captchaRequired.value && (!captchaId.value || captchaAnswer.value == null)) {
|
||||
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Responda a pergunta de verificação antes de enviar.', life: 3000 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
const { data, error } = await supabase.functions.invoke('submit-patient-intake', {
|
||||
body: {
|
||||
token: token.value,
|
||||
payload,
|
||||
website: honeypot.value, // honeypot field
|
||||
captcha_id: captchaId.value || null,
|
||||
captcha_answer: captchaAnswer.value,
|
||||
client_info: clientInfo
|
||||
}
|
||||
});
|
||||
|
||||
// Quando edge devolve 4xx/5xx, supabase-js coloca a Response em error.context.
|
||||
// O body com {error: 'captcha-required' | 'rate-limited' | ...} continua acessível.
|
||||
let errBody = null;
|
||||
if (error?.context?.json) {
|
||||
try { errBody = await error.context.json(); } catch { /* sem body */ }
|
||||
}
|
||||
const errMsg = String(errBody?.error || data?.error || error?.message || '');
|
||||
if (/captcha-required/i.test(errMsg)) {
|
||||
captchaRequired.value = true;
|
||||
captchaId.value = '';
|
||||
captchaAnswer.value = null;
|
||||
toast.add({ severity: 'warn', summary: 'Verificação extra', detail: 'Por segurança, responda a pergunta abaixo.', life: 3500 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (/captcha-wrong/i.test(errMsg)) {
|
||||
captchaId.value = '';
|
||||
captchaAnswer.value = null;
|
||||
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Resposta incorreta. Tente novamente.', life: 3500 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (/rate-limited/i.test(errMsg)) {
|
||||
const retryAfter = errBody?.retry_after_seconds || data?.retry_after_seconds;
|
||||
const after = retryAfter ? Math.ceil(retryAfter / 60) : null;
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Muitas tentativas',
|
||||
detail: after ? `Tente novamente em ${after} min.` : 'Tente novamente mais tarde.',
|
||||
life: 6000
|
||||
});
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) throw new Error(data?.message || error.message || 'Falha ao enviar');
|
||||
if (data?.error) throw new Error(data?.message || data.error);
|
||||
|
||||
sucesso.value = true;
|
||||
toast.add({ severity: 'success', summary: 'Enviado', detail: 'Cadastro enviado com sucesso.', life: 2500 });
|
||||
@@ -620,7 +618,8 @@ watch(
|
||||
|
||||
<!-- actions -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
|
||||
<!-- A#26: Botão de preencher mock apenas em DEV -->
|
||||
<Button v-if="isDev" label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
|
||||
<Button label="Enviar" icon="pi pi-send" class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950" :loading="salvando" :disabled="salvando || sucesso || !tokenOk" @click="enviar" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,52 +634,8 @@ watch(
|
||||
<!-- Left panel (inside modal) -->
|
||||
<aside class="col-span-12 md:col-span-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<!-- Photo -->
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="relative">
|
||||
<div class="h-20 w-20 overflow-hidden rounded-2xl border border-white/10 bg-white/5">
|
||||
<img v-if="fotoPreviewUrl || form.foto_url" :src="fotoPreviewUrl || form.foto_url" alt="foto" class="h-full w-full object-cover" />
|
||||
<div v-else class="grid h-full w-full place-items-center text-slate-300">
|
||||
<i class="pi pi-user text-2xl opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute -inset-1 rounded-2xl bg-emerald-400/10 blur-lg" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">Foto (opcional)</div>
|
||||
<div class="mt-1 text-xs text-slate-300">Se preferir, pode deixar sem foto.</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
|
||||
<input type="file" accept="image/*" class="hidden" :disabled="salvando" @change="onFotoPicked" />
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i class="pi pi-upload" />
|
||||
Enviar imagem
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
|
||||
<input type="file" accept="image/*" capture="user" class="hidden" :disabled="salvando" @change="onFotoPicked" />
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i class="pi pi-camera" />
|
||||
Usar câmera (celular)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="fotoFile" class="flex gap-2">
|
||||
<Button type="button" icon="pi pi-trash" severity="secondary" outlined size="small" label="Remover" class="!border-white/20 !text-slate-100" :disabled="salvando" @click="limparFoto" />
|
||||
</div>
|
||||
|
||||
<div v-if="fotoErro" class="rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
|
||||
{{ fotoErro }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section chips -->
|
||||
<div class="mt-5">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate-200">Seções</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -1124,19 +1079,6 @@ watch(
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 4 -->
|
||||
<AccordionPanel value="4">
|
||||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 4)"> 5. Anotações </AccordionHeader>
|
||||
<AccordionContent>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-file-edit" />
|
||||
<Textarea id="f_notas_internas" v-model="form.notas_internas" class="w-full" autoResize rows="6" variant="filled" :disabled="salvando" />
|
||||
</IconField>
|
||||
<label for="f_notas_internas">Notas</label>
|
||||
</FloatLabel>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
|
||||
<!-- Footer / Consent + CTA -->
|
||||
@@ -1146,7 +1088,12 @@ watch(
|
||||
<Checkbox v-model="consent" :binary="true" :disabled="salvando" inputId="ext_consent" />
|
||||
<div class="min-w-0">
|
||||
<label for="ext_consent" class="block text-sm font-semibold text-slate-100"> Concordo em enviar meus dados para o terapeuta. </label>
|
||||
<div class="mt-1 text-xs text-slate-300">Seus dados serão usados apenas para contato e organização do atendimento.</div>
|
||||
<div class="mt-1 text-xs text-slate-300">
|
||||
Seus dados serão usados apenas para contato e organização do atendimento.
|
||||
<button type="button" class="ml-1 underline text-emerald-300 hover:text-emerald-200 focus:outline-none" @click="policyOpen = true">
|
||||
Ver política de privacidade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errors.consentimento" class="mt-2 rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
|
||||
{{ errors.consentimento }}
|
||||
@@ -1155,6 +1102,25 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A#20 rev2: HONEYPOT invisível — bot preenche tudo, humano nunca vê -->
|
||||
<div class="absolute -left-[9999px]" aria-hidden="true">
|
||||
<label for="ext_website">Não preencha este campo</label>
|
||||
<input
|
||||
id="ext_website"
|
||||
v-model="honeypot"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- A#20 rev2: math captcha CONDICIONAL — só aparece quando edge pedir -->
|
||||
<MathCaptchaChallenge
|
||||
v-if="captchaRequired"
|
||||
v-model:id="captchaId"
|
||||
v-model:answer="captchaAnswer"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs text-slate-300">Se algo falhar, peça um novo link ao terapeuta.</div>
|
||||
|
||||
@@ -1163,7 +1129,7 @@ watch(
|
||||
icon="pi pi-send"
|
||||
class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950 md:!min-w-[240px]"
|
||||
:loading="salvando"
|
||||
:disabled="salvando || sucesso || !tokenOk"
|
||||
:disabled="salvando || sucesso || !tokenOk || (captchaRequired && (!captchaId || captchaAnswer == null))"
|
||||
@click="enviar"
|
||||
/>
|
||||
</div>
|
||||
@@ -1178,5 +1144,70 @@ watch(
|
||||
<div class="mt-4 text-center text-xs text-slate-400">Ao enviar, você confirma que está fornecendo informações verdadeiras e que autoriza o contato.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A#25: Política de privacidade (dialog inline) -->
|
||||
<Dialog
|
||||
v-model:visible="policyOpen"
|
||||
modal
|
||||
header="Política de privacidade"
|
||||
:style="{ width: '92vw', maxWidth: '720px' }"
|
||||
:breakpoints="{ '640px': '96vw' }"
|
||||
:draggable="false"
|
||||
:dismissableMask="true"
|
||||
>
|
||||
<div class="text-sm leading-relaxed text-[var(--text-color)] space-y-4">
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Finalidade do tratamento</h3>
|
||||
<p>Seus dados pessoais são coletados exclusivamente para viabilizar o contato do terapeuta com você e organizar o atendimento clínico. Não são usados para marketing, compartilhamento com terceiros ou perfilamento.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Dados coletados</h3>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Identificação: nome completo, CPF, RG (opcional), data de nascimento, gênero, estado civil, nacionalidade, naturalidade.</li>
|
||||
<li>Contato: telefone(s), e-mail(s), endereço.</li>
|
||||
<li>Contexto: profissão, escolaridade, parente/responsável, origem do contato, observações que você optar por fornecer.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Base legal (LGPD)</h3>
|
||||
<p>Tratamos seus dados com base no <b>consentimento</b> que você fornece ao marcar a caixa correspondente (Art. 7º, I da Lei 13.709/2018), e quando aplicável, também com base na execução de contrato de prestação de serviço terapêutico (Art. 7º, V).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Retenção</h3>
|
||||
<p>Dados de pré-cadastro são mantidos pelo tempo necessário para a conversão em paciente (ou descarte, se não houver andamento). Após início do vínculo terapêutico, seguem as regras aplicáveis ao prontuário clínico (Resolução CFP 001/2009: no mínimo 5 anos após último atendimento).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Seus direitos</h3>
|
||||
<p>A qualquer momento você pode solicitar, sem custo:</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Confirmação da existência de tratamento;</li>
|
||||
<li>Acesso aos seus dados;</li>
|
||||
<li>Correção de dados incompletos, inexatos ou desatualizados;</li>
|
||||
<li>Anonimização, bloqueio ou eliminação de dados desnecessários;</li>
|
||||
<li>Portabilidade dos dados;</li>
|
||||
<li>Eliminação dos dados pessoais tratados com base no consentimento;</li>
|
||||
<li>Revogação do consentimento.</li>
|
||||
</ul>
|
||||
<p class="mt-2">Para exercer qualquer desses direitos, entre em contato diretamente com o profissional que enviou este link.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Segurança</h3>
|
||||
<p>Adotamos medidas técnicas (criptografia em trânsito, controle de acesso, registros de auditoria) e administrativas para proteger seus dados. Ainda assim, nenhum sistema é 100% seguro — comprometa apenas o estritamente necessário.</p>
|
||||
</section>
|
||||
|
||||
<section class="text-xs text-[var(--text-color-secondary)]">
|
||||
<p>Esta política pode ser atualizada. Data da última revisão: 18/04/2026.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Entendi" icon="pi pi-check" @click="policyOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import Chip from 'primevue/chip';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionTab from 'primevue/accordiontab';
|
||||
import Avatar from 'primevue/avatar';
|
||||
import AvatarGroup from 'primevue/avatargroup';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const brandName = 'Psi Quasar'; // ajuste para o nome final do produto
|
||||
const year = computed(() => new Date().getFullYear());
|
||||
|
||||
function go(path) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
function scrollTo(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
const featuredPlanKey = computed(() => {
|
||||
const list = Array.isArray(pricing.value) ? pricing.value : [];
|
||||
const featured = list.find((p) => p && p.is_featured && p.is_visible);
|
||||
return featured?.plan_key || null;
|
||||
});
|
||||
|
||||
function goStart() {
|
||||
if (featuredPlanKey.value) {
|
||||
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/auth/signup');
|
||||
}
|
||||
|
||||
const features = ref([
|
||||
{
|
||||
title: 'Agenda inteligente',
|
||||
desc: 'Configure sua semana, encaixes, bloqueios e visão por dia/semana.',
|
||||
icon: 'pi pi-calendar'
|
||||
},
|
||||
{
|
||||
title: 'Autoagendamento (PRO)',
|
||||
desc: 'Página para o paciente confirmar, agendar e reagendar sem fricção.',
|
||||
icon: 'pi pi-globe',
|
||||
pro: true
|
||||
},
|
||||
{
|
||||
title: 'Prontuário e sessões',
|
||||
desc: 'Registro por paciente, histórico por sessão e organização por linha do tempo.',
|
||||
icon: 'pi pi-file-edit'
|
||||
},
|
||||
{
|
||||
title: 'Financeiro integrado',
|
||||
desc: 'Receitas, despesas e visão do mês conectadas ao que acontece na agenda.',
|
||||
icon: 'pi pi-wallet'
|
||||
},
|
||||
{
|
||||
title: 'Pacientes e tags',
|
||||
desc: 'Segmentação por grupos, etiquetas e filtros práticos para achar rápido.',
|
||||
icon: 'pi pi-users'
|
||||
},
|
||||
{
|
||||
title: 'Clínica / multi-profissional',
|
||||
desc: 'Múltiplos profissionais, agendas separadas, papéis e visão gerencial.',
|
||||
icon: 'pi pi-building'
|
||||
}
|
||||
]);
|
||||
|
||||
/** PRICING dinâmico do SaaS */
|
||||
const billingInterval = ref('year'); // 'month' | 'year'
|
||||
const pricing = ref([]);
|
||||
const loadingPricing = ref(false);
|
||||
|
||||
function formatBRLFromCents(cents) {
|
||||
if (cents == null) return '—';
|
||||
const v = Number(cents) / 100;
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function priceFor(p) {
|
||||
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents;
|
||||
}
|
||||
|
||||
async function fetchPricing() {
|
||||
loadingPricing.value = true;
|
||||
|
||||
const { data, error } = await supabase.from('v_public_pricing').select('*').eq('is_visible', true).order('sort_order', { ascending: true });
|
||||
|
||||
loadingPricing.value = false;
|
||||
|
||||
if (!error) pricing.value = data || [];
|
||||
}
|
||||
|
||||
onMounted(fetchPricing);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
|
||||
<!-- TOPBAR -->
|
||||
<div class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur">
|
||||
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm">
|
||||
<i class="pi pi-sparkles text-lg opacity-80" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
|
||||
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="relative overflow-hidden">
|
||||
<!-- blobs / noir glow -->
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
|
||||
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
|
||||
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
|
||||
<div class="grid grid-cols-12 gap-6 items-center">
|
||||
<div class="col-span-12 lg:col-span-7">
|
||||
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
|
||||
|
||||
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">Uma agenda inteligente, um prontuário organizado, um financeiro respirável.</h1>
|
||||
|
||||
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">Centralize a rotina clínica em um lugar só: pacientes, sessões, lembretes e indicadores. Menos dispersão. Mais presença.</p>
|
||||
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-2">
|
||||
<Button label="Criar conta grátis" icon="pi pi-arrow-right" class="w-full sm:w-auto" @click="goStart()" />
|
||||
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full sm:w-auto" @click="scrollTo('pricing')" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<Tag severity="secondary" value="Agenda online (PRO)" />
|
||||
<Tag severity="secondary" value="Controle de sessões" />
|
||||
<Tag severity="secondary" value="Financeiro integrado" />
|
||||
<Tag severity="secondary" value="Clínica / multi-profissional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<Card class="overflow-hidden">
|
||||
<template #content>
|
||||
<div class="p-1">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-lg">Painel de hoje</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
|
||||
</div>
|
||||
<i class="pi pi-chart-line opacity-70" />
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
|
||||
<div class="text-2xl font-semibold mt-1">6</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
|
||||
<div class="text-2xl font-semibold mt-1">R$ 840</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
|
||||
<div class="font-semibold mt-1">Anotações e histórico</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">organizado por paciente, sessão e linha do tempo</div>
|
||||
</div>
|
||||
<i class="pi pi-file-edit opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">* Ilustração conceitual do produto.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TRUST / VALUE STRIP -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-calendar opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Agenda e autoagendamento</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O paciente confirma, agenda e reagenda com autonomia (PRO).</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-wallet opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Financeiro integrado</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Receita/despesa junto da agenda — sem planilhas espalhadas.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-lock opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Prontuário e controle de sessões</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Registro clínico e histórico acessíveis, com backups e organização.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de clínica.</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O foco é tirar o excesso de fricção sem invadir o que é do seu método.</div>
|
||||
</div>
|
||||
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i :class="f.icon" class="opacity-80" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">{{ f.title }}</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
{{ f.desc }}
|
||||
</div>
|
||||
<div v-if="f.pro" class="mt-2">
|
||||
<Tag severity="warning" value="PRO" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="my-8" />
|
||||
|
||||
<Accordion :activeIndex="0">
|
||||
<AccordionTab header="Como fica o fluxo na prática?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento. O sistema existe para manter o consultório respirando — não para virar uma burocracia nova.
|
||||
</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="E para clínica (multi-profissionais)?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">Perfis por função, agendas separadas, repasses e visão gerencial — quando você estiver pronto para crescer.</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="Privacidade e segurança">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes de conformidade você pode expor numa página própria de segurança/LGPD.)
|
||||
</div>
|
||||
</AccordionTab>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
<!-- PRICING (dinâmico do SaaS) -->
|
||||
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
|
||||
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
|
||||
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">Comece simples. Suba para PRO quando a agenda pedir automação.</div>
|
||||
|
||||
<!-- header conceitual + toggle -->
|
||||
<div class="flex flex-col items-center text-center mt-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<AvatarGroup>
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
|
||||
</AvatarGroup>
|
||||
|
||||
<Divider layout="vertical" />
|
||||
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
|
||||
<Button label="Mensal" size="small" :severity="billingInterval === 'month' ? 'success' : 'secondary'" :outlined="billingInterval !== 'month'" @click="billingInterval = 'month'" />
|
||||
<Button label="Anual" size="small" :severity="billingInterval === 'year' ? 'success' : 'secondary'" :outlined="billingInterval !== 'year'" class="ml-1" @click="billingInterval = 'year'" />
|
||||
</div>
|
||||
<div v-if="billingInterval === 'year'" class="mt-2">
|
||||
<Tag severity="success" value="Economize até 20%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">Carregando planos...</div>
|
||||
|
||||
<div v-else class="mt-8 grid grid-cols-12 gap-4">
|
||||
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full overflow-hidden transition-transform" :class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''">
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">
|
||||
{{ p.badge || 'Plano' }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ p.public_name || p.plan_name || p.plan_key }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tag v-if="p.is_featured" severity="success" value="Popular" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-3xl font-semibold leading-none">
|
||||
{{ formatBRLFromCents(priceFor(p)) }}
|
||||
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
|
||||
|
||||
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
|
||||
{{ p.public_description }}
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
|
||||
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-emerald-500"></i>
|
||||
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
|
||||
{{ b.text }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
label="Começar"
|
||||
class="w-full"
|
||||
:severity="p.is_featured ? 'success' : 'secondary'"
|
||||
:outlined="!p.is_featured"
|
||||
icon="pi pi-arrow-right"
|
||||
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO) sem mexer no código.</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="border-t border-[var(--surface-border)]">
|
||||
<div class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="font-semibold">{{ brandName }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} — Todos os direitos reservados.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
|
||||
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,142 +0,0 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/public/PatientsExternalLinkPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Message from 'primevue/message';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'; // ajuste se seu caminho for diferente
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const inviteToken = ref('');
|
||||
const rotating = ref(false);
|
||||
|
||||
const origin = computed(() => window.location.origin);
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return '';
|
||||
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`;
|
||||
});
|
||||
|
||||
function newToken() {
|
||||
// browsers modernos
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
// fallback simples
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
async function requireUserId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite() {
|
||||
const uid = await requireUserId();
|
||||
|
||||
const { data, error } = await supabase.from('patient_invites').select('token, active').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const token = data?.[0]?.token;
|
||||
if (token) {
|
||||
inviteToken.value = token;
|
||||
return;
|
||||
}
|
||||
|
||||
const t = newToken();
|
||||
const { error: insErr } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
|
||||
|
||||
if (insErr) throw insErr;
|
||||
inviteToken.value = t;
|
||||
}
|
||||
|
||||
async function rotateLink() {
|
||||
rotating.value = true;
|
||||
try {
|
||||
const t = newToken();
|
||||
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t });
|
||||
if (error) throw error;
|
||||
|
||||
inviteToken.value = t;
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 });
|
||||
} finally {
|
||||
rotating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
if (!publicUrl.value) return;
|
||||
await navigator.clipboard.writeText(publicUrl.value);
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 });
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
if (!publicUrl.value) return;
|
||||
window.open(publicUrl.value, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadOrCreateInvite();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER -->
|
||||
<div class="flex flex-column md:flex-row md:align-items-center md:justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="text-900 text-2xl font-semibold">Cadastro Externo</div>
|
||||
<div class="text-600 mt-1">Gere um link para o paciente preencher o pré-cadastro.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined :loading="rotating" @click="rotateLink" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CARD -->
|
||||
<Card class="mt-4">
|
||||
<template #title>Seu link</template>
|
||||
<template #subtitle>Envie este link ao paciente.</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div class="p-inputgroup">
|
||||
<InputText readonly :value="publicUrl" placeholder="Gerando seu link…" />
|
||||
<Button icon="pi pi-copy" severity="secondary" outlined :disabled="!publicUrl" @click="copyLink" v-tooltip.bottom="'Copiar'" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" outlined :disabled="!publicUrl" @click="openLink" v-tooltip.bottom="'Abrir'" />
|
||||
</div>
|
||||
|
||||
<Message v-if="!inviteToken" severity="info" :closable="false"> Gerando seu link... </Message>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,6 +17,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
@@ -273,7 +274,7 @@ async function loadIntents() {
|
||||
totalIntentsNew.value = Number(cNew || 0);
|
||||
totalIntentsPaid.value = Number(cPaid || 0);
|
||||
} catch (e) {
|
||||
console.warn('[SAAS] loadIntents failed:', e);
|
||||
logError('SaasDashboard', 'loadIntents failed', e);
|
||||
intents.value = [];
|
||||
totalIntents.value = 0;
|
||||
totalIntentsNew.value = 0;
|
||||
|
||||
@@ -329,10 +329,6 @@ async function load() {
|
||||
const { data, error } = await supabase.from('saas_docs').select('*').order('pagina_path').order('ordem');
|
||||
if (error) throw error;
|
||||
docs.value = data || [];
|
||||
console.log(
|
||||
'docs carregados:',
|
||||
docs.value.map((d) => ({ titulo: d.titulo, votos_util: d.votos_util, votos_nao_util: d.votos_nao_util }))
|
||||
);
|
||||
setDocs(docs.value);
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 });
|
||||
|
||||
@@ -33,6 +33,7 @@ const saving = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const q = ref('');
|
||||
const showInactive = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
@@ -86,8 +87,10 @@ const filteredRows = computed(() => {
|
||||
const term = String(q.value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!term) return rows.value;
|
||||
return (rows.value || []).filter((r) => {
|
||||
let list = rows.value || [];
|
||||
if (!showInactive.value) list = list.filter((r) => r.is_active !== false);
|
||||
if (!term) return list;
|
||||
return list.filter((r) => {
|
||||
return [r.key, r.name, r.descricao].some((s) =>
|
||||
String(s || '')
|
||||
.toLowerCase()
|
||||
@@ -99,7 +102,7 @@ const filteredRows = computed(() => {
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('features').select('id, key, name, descricao, created_at').order('key', { ascending: true });
|
||||
const { data, error } = await supabase.from('features').select('id, key, name, descricao, created_at, is_active').order('key', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
rows.value = data || [];
|
||||
@@ -192,26 +195,40 @@ async function save() {
|
||||
|
||||
function askDelete(row) {
|
||||
confirm.require({
|
||||
message: `Excluir o recurso "${row.key}"?`,
|
||||
header: 'Confirmar exclusão',
|
||||
message: `Depreciar o recurso "${row.key}"? Tenants que já têm o recurso continuam com ele; só some do catálogo.`,
|
||||
header: 'Depreciar recurso',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => doDelete(row)
|
||||
accept: () => doSoftDelete(row, false)
|
||||
});
|
||||
}
|
||||
|
||||
async function doDelete(row) {
|
||||
function askReactivate(row) {
|
||||
confirm.require({
|
||||
message: `Reativar o recurso "${row.key}"? Volta ao catálogo e fica disponível para novos planos.`,
|
||||
header: 'Reativar recurso',
|
||||
icon: 'pi pi-check',
|
||||
acceptClass: 'p-button-success',
|
||||
accept: () => doSoftDelete(row, true)
|
||||
});
|
||||
}
|
||||
|
||||
async function doSoftDelete(row, reactivate) {
|
||||
try {
|
||||
const { error } = await supabase.from('features').delete().eq('id', row.id);
|
||||
const { error } = await supabase.from('features').update({ is_active: !!reactivate }).eq('id', row.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso excluído.', life: 2500 });
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Ok',
|
||||
detail: reactivate ? 'Recurso reativado.' : 'Recurso depreciado.',
|
||||
life: 2500
|
||||
});
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
const hint = isFkViolation(e) ? 'Este recurso está vinculado a planos ou módulos. Remova o vínculo antes de excluir.' : '';
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: hint ? `${e?.message} — ${hint}` : e?.message || String(e),
|
||||
detail: e?.message || String(e),
|
||||
life: 5200
|
||||
});
|
||||
}
|
||||
@@ -288,7 +305,7 @@ onBeforeUnmount(() => {
|
||||
<!-- content -->
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<FloatLabel variant="on" class="w-full md:w-[380px]">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
@@ -296,6 +313,10 @@ onBeforeUnmount(() => {
|
||||
</IconField>
|
||||
<label for="features_search">Buscar por key, nome ou descrição</label>
|
||||
</FloatLabel>
|
||||
<label class="inline-flex items-center gap-2 text-[0.95rem] text-[var(--text-color-secondary)] cursor-pointer">
|
||||
<input type="checkbox" v-model="showInactive" class="accent-current" />
|
||||
Mostrar depreciados
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
||||
@@ -330,11 +351,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<Column field="created_at" header="Criado em" sortable style="width: 13rem" />
|
||||
|
||||
<Column header="Ações" style="width: 10rem">
|
||||
<Column header="Status" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.is_active === false ? 'depreciado' : 'ativo'" :severity="data.is_active === false ? 'warn' : 'success'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
|
||||
<Button v-if="data.is_active !== false" icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" v-tooltip.top="'Depreciar'" />
|
||||
<Button v-else icon="pi pi-replay" severity="success" outlined @click="askReactivate(data)" v-tooltip.top="'Reativar'" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -354,32 +354,26 @@ function askDelete(row) {
|
||||
});
|
||||
}
|
||||
|
||||
async function disableActivePrices(planId) {
|
||||
const nowIso = new Date().toISOString();
|
||||
const { error } = await supabase.from('plan_prices').update({ is_active: false, active_to: nowIso }).eq('plan_id', planId).eq('is_active', true);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async function doDelete(row) {
|
||||
try {
|
||||
await disableActivePrices(row.id);
|
||||
|
||||
const { error } = await supabase.from('plans').delete().eq('id', row.id);
|
||||
|
||||
// V#36: RPC delete_plan_safe valida subscriptions ativas atomicamente
|
||||
// (desativa prices + deleta plan no mesmo batch). Bloqueia se houver
|
||||
// subscriptions ativas para impedir órfãos.
|
||||
const { error } = await supabase.rpc('delete_plan_safe', { p_plan_id: row.id });
|
||||
if (error) throw error;
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Plano excluído.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
const hint = isFkViolation(e) ? 'Esse plano ainda está referenciado (ex.: plan_features, subscriptions ou pricing). Remova vínculos antes de excluir.' : '';
|
||||
const isBusy = msg.toLowerCase().includes('assinatura') && msg.toLowerCase().includes('ativa');
|
||||
const hint = isFkViolation(e) ? 'Esse plano ainda está referenciado (ex.: plan_features ou pricing). Remova vínculos antes de excluir.' : '';
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
severity: isBusy ? 'warn' : 'error',
|
||||
summary: isBusy ? 'Plano em uso' : 'Erro',
|
||||
detail: hint ? `${msg} — ${hint}` : msg,
|
||||
life: 5200
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SaaS / Segurança (A#20 rev2)
|
||||
|--------------------------------------------------------------------------
|
||||
| Painel saas_admin para configurar a defesa em camadas e ver telemetria.
|
||||
|
|
||||
| Tabela: saas_security_config (singleton)
|
||||
| Logs: public_submission_attempts (genérico, todos endpoints)
|
||||
| Estado: submission_rate_limits (IPs com contadores e bloqueios)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
import AccordionContent from 'primevue/accordioncontent';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const helpOpen = ref(true); // accordion abre fechado depois da primeira leitura
|
||||
|
||||
const cfg = ref(null);
|
||||
const cfgLoading = ref(false);
|
||||
const cfgSaving = ref(false);
|
||||
|
||||
const stats = ref({ total24h: 0, blocked24h: 0, success24h: 0, captcha24h: 0 });
|
||||
const recentAttempts = ref([]);
|
||||
const blockedIps = ref([]);
|
||||
const dataLoading = ref(false);
|
||||
|
||||
async function loadConfig() {
|
||||
cfgLoading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('saas_security_config').select('*').eq('id', true).maybeSingle();
|
||||
if (error) throw error;
|
||||
cfg.value = data || null;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha config', life: 4000 });
|
||||
} finally {
|
||||
cfgLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!cfg.value) return;
|
||||
cfgSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
honeypot_enabled: cfg.value.honeypot_enabled,
|
||||
rate_limit_enabled: cfg.value.rate_limit_enabled,
|
||||
rate_limit_window_min: cfg.value.rate_limit_window_min,
|
||||
rate_limit_max_attempts: cfg.value.rate_limit_max_attempts,
|
||||
captcha_after_failures: cfg.value.captcha_after_failures,
|
||||
captcha_required_globally: cfg.value.captcha_required_globally,
|
||||
block_duration_min: cfg.value.block_duration_min,
|
||||
captcha_required_window_min: cfg.value.captcha_required_window_min
|
||||
};
|
||||
const { error } = await supabase.from('saas_security_config').update(payload).eq('id', true);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada.', life: 2500 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha ao salvar', life: 4000 });
|
||||
} finally {
|
||||
cfgSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
dataLoading.value = true;
|
||||
try {
|
||||
const since = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
||||
|
||||
const [{ data: attempts, error: e1 }, { data: blocked, error: e2 }] = await Promise.all([
|
||||
supabase
|
||||
.from('public_submission_attempts')
|
||||
.select('id, endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, created_at')
|
||||
.gte('created_at', since)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100),
|
||||
supabase
|
||||
.from('submission_rate_limits')
|
||||
.select('*')
|
||||
.order('last_attempt_at', { ascending: false })
|
||||
.limit(50)
|
||||
]);
|
||||
|
||||
if (e1) throw e1;
|
||||
if (e2) throw e2;
|
||||
|
||||
recentAttempts.value = attempts || [];
|
||||
blockedIps.value = blocked || [];
|
||||
|
||||
const total = (attempts || []).length;
|
||||
const success = (attempts || []).filter((a) => a.success).length;
|
||||
const blockedC = (attempts || []).filter((a) => !a.success && a.blocked_by && a.blocked_by !== 'rpc').length;
|
||||
const captchaC = (attempts || []).filter((a) => a.blocked_by === 'captcha').length;
|
||||
|
||||
stats.value = { total24h: total, blocked24h: blockedC, success24h: success, captcha24h: captchaC };
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha dashboard', life: 4000 });
|
||||
} finally {
|
||||
dataLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function blockedSeverity(by) {
|
||||
return ({
|
||||
honeypot: 'info',
|
||||
rate_limit: 'warn',
|
||||
captcha: 'warn',
|
||||
validation: 'secondary',
|
||||
rpc: 'danger'
|
||||
})[by] || 'secondary';
|
||||
}
|
||||
|
||||
function fmtRelative(ts) {
|
||||
if (!ts) return '—';
|
||||
const diff = (Date.now() - new Date(ts).getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s atrás`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}min atrás`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h atrás`;
|
||||
return new Date(ts).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
const ipsCurrentlyBlocked = computed(() => {
|
||||
const now = Date.now();
|
||||
return blockedIps.value.filter((r) => r.blocked_until && new Date(r.blocked_until).getTime() > now);
|
||||
});
|
||||
|
||||
const ipsRequiringCaptcha = computed(() => {
|
||||
const now = Date.now();
|
||||
return blockedIps.value.filter((r) => r.requires_captcha_until && new Date(r.requires_captcha_until).getTime() > now);
|
||||
});
|
||||
|
||||
async function clearRateLimit(row) {
|
||||
confirm.require({
|
||||
message: `Limpar rate limit de ${row.ip_hash.slice(0, 8)}… em ${row.endpoint}?`,
|
||||
header: 'Limpar bloqueio',
|
||||
icon: 'pi pi-question-circle',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('submission_rate_limits').delete().eq('ip_hash', row.ip_hash).eq('endpoint', row.endpoint);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Bloqueio limpo.', life: 2500 });
|
||||
await loadDashboard();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha', life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadConfig(), loadDashboard()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight">Segurança — defesa contra bots</div>
|
||||
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">Configuração da defesa em camadas para endpoints públicos (cadastro de paciente, signup, agendador).</div>
|
||||
</div>
|
||||
|
||||
<!-- Card explicativo (colapsável) -->
|
||||
<div class="rounded-md border border-indigo-400/30 bg-indigo-400/5">
|
||||
<Accordion :value="helpOpen ? '0' : null" @update:value="(v) => helpOpen = (v === '0')">
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-info-circle text-indigo-400" />
|
||||
<span class="font-semibold">Como funciona — guia de uso</span>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-col gap-5 text-[0.92rem] leading-relaxed">
|
||||
|
||||
<!-- ── Visão geral ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-shield mr-2 text-emerald-400" />
|
||||
Visão geral
|
||||
</div>
|
||||
<p class="text-[var(--text-color-secondary)]">
|
||||
Toda submissão na página pública (cadastro de paciente externo) passa por <b>5 camadas em ordem</b>. Se qualquer uma rejeitar, o submit é bloqueado e logado.
|
||||
O design prioriza <b>zero fricção pro paciente legítimo</b> — captcha visível só aparece quando o sistema desconfia.
|
||||
</p>
|
||||
<ol class="list-decimal pl-6 mt-3 space-y-1.5 text-[var(--text-color-secondary)]">
|
||||
<li><b>Honeypot</b> — campo invisível que humano nunca vê. Bot scraper preenche tudo e cai aqui. <span class="opacity-70">(zero fricção)</span></li>
|
||||
<li><b>Validação básica</b> — payload obrigatório, token presente.</li>
|
||||
<li><b>Rate limit por IP</b> — N tentativas por janela de tempo, do mesmo IP+endpoint. Excedeu, bloqueia por X min.</li>
|
||||
<li><b>Math captcha condicional</b> — só ativa se o IP teve N falhas recentes. Mostra "Quanto é 7+4?".</li>
|
||||
<li><b>RPC do intake</b> — só executa se passou pelas camadas 1-4. Validação final dos dados.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ── Cada controle ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-sliders-h mr-2 text-amber-400" />
|
||||
O que cada controle faz
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Honeypot</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Toggle que liga/desliga o campo invisível. Default <b>ON</b>. Praticamente sem custo, mantenha sempre ligado.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Rate limit</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Toggle mestre. Se desligar, ninguém é bloqueado por excesso. Default <b>ON</b>.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-rose-400/40 p-3">
|
||||
<div class="font-medium text-rose-300">Modo paranoid</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
Liga: <b>todo submit exige captcha</b>, mesmo IPs novos. Use SÓ sob ataque ativo (você verá centenas de tentativas no dashboard).
|
||||
Pacientes legítimos vão precisar resolver math toda vez. Lembre de desligar quando passar.
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Janela (min)</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Período de medição. Default <b>10</b>. Tentativas mais antigas que isso são esquecidas.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Máx tentativas na janela</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Quantos submits do mesmo IP toleramos antes de bloquear. Default <b>5</b>. Aumentar se for ambiente compartilhado (ex: WiFi de clínica com vários terapeutas).</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Falhas até captcha</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Após N <b>falhas</b> (não tentativas), o IP passa a precisar resolver math. Default <b>3</b>. Mais baixo = mais paranoico.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Duração do bloqueio</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Quanto tempo o IP fica bloqueado depois de exceder o máximo. Default <b>30 min</b>.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Janela do captcha condicional</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Por quanto tempo o IP suspeito continua precisando de captcha após disparar. Default <b>60 min</b>.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Como testar local ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-desktop mr-2 text-cyan-400" />
|
||||
Como testar localmente
|
||||
</div>
|
||||
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
|
||||
<li>
|
||||
Suba a edge function:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase functions serve submit-patient-intake</pre>
|
||||
</li>
|
||||
<li>
|
||||
Logue como terapeuta, gere um link de cadastro de paciente em
|
||||
<span class="font-mono text-xs">/admin/clinic/intake</span>, copie o link.
|
||||
</li>
|
||||
<li>
|
||||
Abra o link em uma <b>aba anônima</b> (pra simular paciente). Preencha e envie. Caminho feliz: zero fricção, vai cair em <span class="font-mono text-xs">patient_intake_requests</span>.
|
||||
</li>
|
||||
<li>
|
||||
Pra simular bot: abra DevTools → Console e dispare 4 submits inválidos:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">for (let i=0; i<4; i++) {
|
||||
await fetch('http://127.0.0.1:54321/functions/v1/submit-patient-intake', {
|
||||
method: 'POST',
|
||||
headers: {'content-type':'application/json', apikey: '<ANON_KEY>'},
|
||||
body: JSON.stringify({ token: 'ruim', payload: {} })
|
||||
})
|
||||
}</pre>
|
||||
</li>
|
||||
<li>Recarregue esta tela: você verá o IP listado em <b>"IPs ativos"</b> com tag amarela <b>"exige captcha"</b>. Próximo submit do mesmo IP terá o math captcha visível.</li>
|
||||
<li>Pra simular bloqueio total: faça 6+ tentativas. IP fica com tag vermelha <b>"bloqueado"</b> por 30 min. Botão <i class="pi pi-eraser" /> remove o bloqueio manualmente.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ── Como testar online ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-cloud mr-2 text-emerald-400" />
|
||||
Como testar em produção
|
||||
</div>
|
||||
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
|
||||
<li>
|
||||
Deploy da edge function:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase functions deploy submit-patient-intake</pre>
|
||||
</li>
|
||||
<li>Sem configuração de chave externa — defesa é 100% self-hosted, funciona já após o deploy.</li>
|
||||
<li>
|
||||
Crie um link de teste no painel de terapeuta. Acesse de um celular (rede 4G — IP diferente do WiFi) para simular paciente real.
|
||||
</li>
|
||||
<li>Tente fluxo correto + tente abusar (4 submits errados). Volte aqui pra ver os logs em <b>"Tentativas recentes"</b>.</li>
|
||||
<li>Se notar IP brasileiro legítimo sendo bloqueado falsamente, aumente <b>"Máx tentativas na janela"</b> ou diminua <b>"Falhas até captcha"</b>.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ── Quando usar paranoid ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-exclamation-triangle mr-2 text-rose-400" />
|
||||
Quando ligar o modo paranoid
|
||||
</div>
|
||||
<p class="text-[var(--text-color-secondary)]">
|
||||
Use SÓ se você ver no dashboard <b>centenas de tentativas/hora</b> de IPs distintos (ataque distribuído real).
|
||||
Como cada paciente legítimo vai ter que resolver math toda vez, é fricção alta — só vale se a alternativa for floods de cadastro fake.
|
||||
</p>
|
||||
<p class="text-[var(--text-color-secondary)] mt-2">
|
||||
<b>Lembre de desligar</b> quando o ataque cessar (24-48h depois normalmente). Defina lembrete.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── O que NÃO protege ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-info-circle mr-2 text-amber-400" />
|
||||
Limitações honestas
|
||||
</div>
|
||||
<ul class="list-disc pl-6 space-y-1 text-[var(--text-color-secondary)]">
|
||||
<li>Bot <b>dedicado</b> com proxy rotativo + LLM resolvendo math <b>passa</b>. Mas o custo do ataque sobe muito.</li>
|
||||
<li>Vários pacientes na mesma rede (clínica com WiFi compartilhado) podem disparar rate limit. Se acontecer, ajuste o slider.</li>
|
||||
<li>Math captcha não é acessível pra todos (deficiência cognitiva, dislexia severa). Se for crítico, diminua <b>"Falhas até captcha"</b> alto pra maior tolerância.</li>
|
||||
<li>Logs em <span class="font-mono text-xs">public_submission_attempts</span> crescem indefinidamente. Configure cron de limpeza após 30-90 dias.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Submits 24h</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ stats.total24h }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Bem-sucedidos</div>
|
||||
<div class="text-2xl font-bold mt-1 text-emerald-500">{{ stats.success24h }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Bloqueados (bot)</div>
|
||||
<div class="text-2xl font-bold mt-1 text-amber-500">{{ stats.blocked24h }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Captchas barrados</div>
|
||||
<div class="text-2xl font-bold mt-1 text-rose-500">{{ stats.captcha24h }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuração -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold mb-4">Configuração global</div>
|
||||
|
||||
<div v-if="cfg" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Honeypot</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Campo invisível que bots preenchem.</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.honeypot_enabled" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Rate limit</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Limite de submits por IP por janela.</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.rate_limit_enabled" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-rose-400/40 bg-rose-400/5 p-3 md:col-span-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-rose-300">Modo paranoid</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Liga: TODO submit exige captcha, mesmo IP novo. Use só sob ataque ativo.</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.captcha_required_globally" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Janela (min)</label>
|
||||
<InputNumber v-model="cfg.rate_limit_window_min" :min="1" :max="1440" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Máx tentativas na janela</label>
|
||||
<InputNumber v-model="cfg.rate_limit_max_attempts" :min="1" :max="100" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Falhas até exigir captcha</label>
|
||||
<InputNumber v-model="cfg.captcha_after_failures" :min="1" :max="20" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Duração do bloqueio (min)</label>
|
||||
<InputNumber v-model="cfg.block_duration_min" :min="1" :max="1440" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Janela do captcha condicional (min)</label>
|
||||
<InputNumber v-model="cfg.captcha_required_window_min" :min="1" :max="1440" class="w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<Button label="Recarregar" icon="pi pi-refresh" outlined @click="loadConfig" :disabled="cfgSaving" />
|
||||
<Button label="Salvar" icon="pi pi-check" @click="saveConfig" :loading="cfgSaving" :disabled="!cfg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPs em estado ativo -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-semibold">IPs ativos (rate limit)</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ ipsCurrentlyBlocked.length }} bloqueados · {{ ipsRequiringCaptcha.length }} exigindo captcha
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :value="blockedIps" :loading="dataLoading" size="small" stripedRows responsiveLayout="scroll">
|
||||
<Column header="IP">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.ip_hash?.slice(0, 12) }}…</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="endpoint" header="Endpoint" />
|
||||
<Column header="Tentativas" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
{{ data.fail_count }}/{{ data.attempt_count }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Estado">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.blocked_until && new Date(data.blocked_until) > new Date()" value="bloqueado" severity="danger" />
|
||||
<Tag v-else-if="data.requires_captcha_until && new Date(data.requires_captcha_until) > new Date()" value="exige captcha" severity="warn" />
|
||||
<Tag v-else value="ok" severity="success" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Última">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ fmtRelative(data.last_attempt_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Ações" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-eraser" text size="small" @click="clearRateLimit(data)" v-tooltip.top="'Limpar bloqueio'" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Tentativas recentes -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-semibold">Tentativas recentes (24h)</div>
|
||||
<Button icon="pi pi-refresh" text size="small" @click="loadDashboard" :loading="dataLoading" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="recentAttempts" :loading="dataLoading" size="small" stripedRows responsiveLayout="scroll" :paginator="true" :rows="20">
|
||||
<Column header="Quando">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ fmtRelative(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="endpoint" header="Endpoint" />
|
||||
<Column header="IP">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs opacity-70">{{ data.ip_hash?.slice(0, 8) || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Estado" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.success" value="ok" severity="success" />
|
||||
<Tag v-else :value="data.blocked_by || 'erro'" :severity="blockedSeverity(data.blocked_by)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="error_code" header="Motivo">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ data.error_code || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,8 +19,11 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { createSupportSession, listActiveSupportSessions, listSessionHistory, revokeSupportSession, buildSupportUrl } from '@/support/supportSessionService';
|
||||
import { logEvent, logError } from '@/support/supportLogger';
|
||||
|
||||
const TAG = '[SaasSupportPage]';
|
||||
const TAG = 'SaasSupportPage';
|
||||
const isDev = import.meta.env.DEV;
|
||||
const dev = (msg, data) => { if (isDev) logEvent(TAG, msg, data); };
|
||||
const toast = useToast();
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
@@ -84,7 +87,7 @@ const activeSessionCount = computed(() => activeSessions.value.length);
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
console.log(`${TAG} montado`);
|
||||
dev('mounted');
|
||||
await loadTenants();
|
||||
await loadActiveSessions();
|
||||
startTick();
|
||||
@@ -93,14 +96,13 @@ onMounted(async () => {
|
||||
// ── Tenants ───────────────────────────────────────────────────────────────────
|
||||
async function loadTenants() {
|
||||
loadingTenants.value = true;
|
||||
console.log(`${TAG} loadTenants`);
|
||||
try {
|
||||
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const list = data || [];
|
||||
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`);
|
||||
dev(`${list.length} tenant(s) carregado(s)`);
|
||||
|
||||
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
|
||||
tenants.value = list.map((t) => ({
|
||||
@@ -108,7 +110,7 @@ async function loadTenants() {
|
||||
label: `${t.name} (${t.kind ?? 'tenant'})`
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadTenants ERRO`, e);
|
||||
logError(TAG, 'loadTenants', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingTenants.value = false;
|
||||
@@ -118,12 +120,11 @@ async function loadTenants() {
|
||||
// ── Sessões ativas ─────────────────────────────────────────────────────────────
|
||||
async function loadActiveSessions() {
|
||||
loadingSessions.value = true;
|
||||
console.log(`${TAG} loadActiveSessions`);
|
||||
try {
|
||||
activeSessions.value = await listActiveSupportSessions();
|
||||
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`);
|
||||
dev(`${activeSessions.value.length} sessão(ões) ativa(s)`);
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadActiveSessions ERRO`, e);
|
||||
logError(TAG, 'loadActiveSessions', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingSessions.value = false;
|
||||
@@ -134,12 +135,11 @@ async function loadActiveSessions() {
|
||||
async function loadHistory() {
|
||||
if (loadingHistory.value) return;
|
||||
loadingHistory.value = true;
|
||||
console.log(`${TAG} loadHistory`);
|
||||
try {
|
||||
sessionHistory.value = await listSessionHistory(100);
|
||||
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`);
|
||||
dev(`histórico: ${sessionHistory.value.length} registro(s)`);
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadHistory ERRO`, e);
|
||||
logError(TAG, 'loadHistory', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingHistory.value = false;
|
||||
@@ -153,19 +153,17 @@ async function handleCreate() {
|
||||
generatedUrl.value = null;
|
||||
generatedData.value = null;
|
||||
|
||||
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' });
|
||||
dev('handleCreate', { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value });
|
||||
|
||||
try {
|
||||
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value);
|
||||
generatedData.value = result;
|
||||
generatedUrl.value = buildSupportUrl(result.token);
|
||||
|
||||
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0, 8)}…`, expires_at: result.expires_at });
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 });
|
||||
await loadActiveSessions();
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleCreate ERRO`, e);
|
||||
logError(TAG, 'handleCreate', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 });
|
||||
} finally {
|
||||
creating.value = false;
|
||||
@@ -175,7 +173,6 @@ async function handleCreate() {
|
||||
// ── Revogar ───────────────────────────────────────────────────────────────────
|
||||
async function handleRevoke(token) {
|
||||
revokingToken.value = token;
|
||||
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}…`);
|
||||
try {
|
||||
await revokeSupportSession(token);
|
||||
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 });
|
||||
@@ -186,7 +183,7 @@ async function handleRevoke(token) {
|
||||
await loadActiveSessions();
|
||||
if (sessionHistory.value.length) await loadHistory();
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleRevoke ERRO`, e);
|
||||
logError(TAG, 'handleRevoke', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
revokingToken.value = null;
|
||||
@@ -197,7 +194,6 @@ async function handleRevoke(token) {
|
||||
function copyUrl(url) {
|
||||
if (!url) return;
|
||||
navigator.clipboard.writeText(url);
|
||||
console.log(`${TAG} URL copiada`);
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 });
|
||||
}
|
||||
|
||||
@@ -205,7 +201,6 @@ function copyUrl(url) {
|
||||
function onTabChange(e) {
|
||||
const idx = e.index ?? e;
|
||||
activeTab.value = idx;
|
||||
console.log(`${TAG} tab mudou para ${idx}`);
|
||||
if (idx === 2 && sessionHistory.value.length === 0) loadHistory();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/saas/SaasTenantFeaturesPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import Select from 'primevue/select';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const tenants = ref([]);
|
||||
const features = ref([]);
|
||||
const selectedTenantId = ref(null);
|
||||
|
||||
const planAllowed = ref(new Set());
|
||||
const planKey = ref(null);
|
||||
const overrides = ref({});
|
||||
const exceptionsLog = ref([]);
|
||||
|
||||
const filterText = ref('');
|
||||
|
||||
const dlg = ref({
|
||||
open: false,
|
||||
feature: null,
|
||||
nextEnabled: false,
|
||||
isException: false,
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const selectedTenant = computed(() => tenants.value.find((t) => t.id === selectedTenantId.value) || null);
|
||||
|
||||
const rows = computed(() => {
|
||||
const ft = String(filterText.value || '').trim().toLowerCase();
|
||||
return features.value
|
||||
.filter((f) => !ft || f.key.toLowerCase().includes(ft) || (f.name || '').toLowerCase().includes(ft))
|
||||
.map((f) => {
|
||||
const inPlan = planAllowed.value.has(f.key);
|
||||
const ov = overrides.value[f.key];
|
||||
let status = 'inactive';
|
||||
if (ov === true) status = inPlan ? 'active_plan' : 'exception';
|
||||
else if (ov === false) status = 'off_pref';
|
||||
else status = inPlan ? 'active_plan' : 'inactive';
|
||||
return { ...f, in_plan: inPlan, override: ov, status };
|
||||
});
|
||||
});
|
||||
|
||||
function statusLabel(s) {
|
||||
return (
|
||||
{
|
||||
active_plan: 'Ativa (plano)',
|
||||
off_pref: 'Desligada (preferência)',
|
||||
exception: 'Exceção comercial',
|
||||
inactive: 'Não disponível'
|
||||
}[s] || s
|
||||
);
|
||||
}
|
||||
|
||||
function statusSeverity(s) {
|
||||
return (
|
||||
{
|
||||
active_plan: 'success',
|
||||
off_pref: 'warn',
|
||||
exception: 'info',
|
||||
inactive: 'secondary'
|
||||
}[s] || 'secondary'
|
||||
);
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
const { data, error } = await supabase.from('tenants').select('id, name').order('name', { ascending: true });
|
||||
if (error) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar clínicas', life: 4000 });
|
||||
return;
|
||||
}
|
||||
tenants.value = data || [];
|
||||
}
|
||||
|
||||
async function loadFeatures() {
|
||||
const { data, error } = await supabase.from('features').select('id, key, name, descricao').order('key', { ascending: true });
|
||||
if (error) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar catálogo', life: 4000 });
|
||||
return;
|
||||
}
|
||||
features.value = data || [];
|
||||
}
|
||||
|
||||
async function loadTenantState(tenantId) {
|
||||
if (!tenantId) {
|
||||
planAllowed.value = new Set();
|
||||
planKey.value = null;
|
||||
overrides.value = {};
|
||||
exceptionsLog.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const [{ data: ent, error: e1 }, { data: ovr, error: e2 }, { data: sub, error: e3 }, { data: log, error: e4 }] = await Promise.all([
|
||||
supabase.from('v_tenant_entitlements').select('feature_key').eq('tenant_id', tenantId),
|
||||
supabase.from('tenant_features').select('feature_key, enabled').eq('tenant_id', tenantId),
|
||||
supabase.from('v_tenant_active_subscription').select('plan_key').eq('tenant_id', tenantId).maybeSingle(),
|
||||
supabase.from('tenant_feature_exceptions_log').select('feature_key, enabled, reason, created_by, created_at').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(50)
|
||||
]);
|
||||
|
||||
if (e1) throw e1;
|
||||
if (e2) throw e2;
|
||||
if (e3) throw e3;
|
||||
if (e4) throw e4;
|
||||
|
||||
const set = new Set();
|
||||
for (const r of ent || []) set.add(r.feature_key);
|
||||
planAllowed.value = set;
|
||||
|
||||
const map = {};
|
||||
for (const r of ovr || []) map[r.feature_key] = !!r.enabled;
|
||||
overrides.value = map;
|
||||
|
||||
planKey.value = sub?.plan_key || null;
|
||||
exceptionsLog.value = log || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar tenant', detail: e?.message || 'falha', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(feature, nextEnabled) {
|
||||
const isException = nextEnabled === true && !planAllowed.value.has(feature.key);
|
||||
dlg.value = {
|
||||
open: true,
|
||||
feature,
|
||||
nextEnabled,
|
||||
isException,
|
||||
reason: ''
|
||||
};
|
||||
}
|
||||
|
||||
async function confirmChange() {
|
||||
const { feature, nextEnabled, isException } = dlg.value;
|
||||
const reason = String(dlg.value.reason || '').trim();
|
||||
|
||||
if (isException && reason.length < 4) {
|
||||
toast.add({ severity: 'warn', summary: 'Motivo obrigatório', detail: 'Exceção comercial exige motivo (≥ 4 caracteres).', life: 3500 });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.rpc('set_tenant_feature_exception', {
|
||||
p_tenant_id: selectedTenantId.value,
|
||||
p_feature_key: feature.key,
|
||||
p_enabled: nextEnabled,
|
||||
p_reason: reason || null
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Atualizado',
|
||||
detail: `${feature.name || feature.key}: ${nextEnabled ? 'ativada' : 'desativada'}${isException ? ' (exceção)' : ''}`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
dlg.value.open = false;
|
||||
await loadTenantState(selectedTenantId.value);
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'erro ao alterar feature', life: 4500 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearOverride(feature) {
|
||||
// "Limpar override" = setar conforme o plano (volta ao padrão)
|
||||
const back = planAllowed.value.has(feature.key);
|
||||
openDialog(feature, back);
|
||||
dlg.value.reason = 'Limpeza de override (volta ao padrão do plano)';
|
||||
}
|
||||
|
||||
watch(selectedTenantId, async (id) => {
|
||||
await loadTenantState(id);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTenants(), loadFeatures()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos por Clínica</div>
|
||||
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">Gerencia overrides de features por tenant. Exceções comerciais (ativar feature fora do plano) exigem motivo e ficam logadas.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="text-[0.85rem] text-[var(--text-color-secondary)]">Clínica</label>
|
||||
<Select v-model="selectedTenantId" :options="tenants" optionLabel="name" optionValue="id" placeholder="Selecione…" class="w-full mt-1" filter showClear />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-end">
|
||||
<div v-if="selectedTenant" class="text-[0.95rem]">
|
||||
<span class="text-[var(--text-color-secondary)]">Plano ativo:</span>
|
||||
<Tag :value="planKey || 'sem assinatura'" :severity="planKey ? 'info' : 'warn'" class="ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTenantId" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div class="font-semibold">Catálogo de features</div>
|
||||
<InputText v-model="filterText" placeholder="filtrar por chave ou nome…" class="max-w-xs" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="rows" :loading="loading" dataKey="id" responsiveLayout="scroll" size="small" stripedRows>
|
||||
<Column field="key" header="Chave" sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-[0.85rem]">{{ data.key }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Nome" sortable />
|
||||
<Column header="No plano">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.in_plan ? 'sim' : 'não'" :severity="data.in_plan ? 'success' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Estado">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Ações" style="width: 320px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button v-if="data.status === 'active_plan'" label="Desligar (preferência)" icon="pi pi-times" size="small" severity="warn" outlined @click="openDialog(data, false)" />
|
||||
|
||||
<Button v-if="data.status === 'off_pref'" label="Religar" icon="pi pi-check" size="small" severity="success" outlined @click="openDialog(data, true)" />
|
||||
|
||||
<Button v-if="data.status === 'inactive'" label="Liberar exceção" icon="pi pi-key" size="small" severity="info" outlined @click="openDialog(data, true)" />
|
||||
|
||||
<Button v-if="data.status === 'exception'" label="Cancelar exceção" icon="pi pi-undo" size="small" severity="danger" outlined @click="openDialog(data, false)" />
|
||||
|
||||
<Button v-if="data.override !== undefined" label="Limpar override" icon="pi pi-eraser" size="small" text @click="clearOverride(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTenantId && exceptionsLog.length" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold mb-3">Histórico de mudanças (50 mais recentes)</div>
|
||||
<DataTable :value="exceptionsLog" size="small" stripedRows responsiveLayout="scroll">
|
||||
<Column header="Quando">
|
||||
<template #body="{ data }">{{ new Date(data.created_at).toLocaleString('pt-BR') }}</template>
|
||||
</Column>
|
||||
<Column field="feature_key" header="Feature">
|
||||
<template #body="{ data }"><span class="font-mono text-[0.85rem]">{{ data.feature_key }}</span></template>
|
||||
</Column>
|
||||
<Column header="Estado">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.enabled ? 'on' : 'off'" :severity="data.enabled ? 'success' : 'warn'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="reason" header="Motivo">
|
||||
<template #body="{ data }">
|
||||
<span :class="!data.reason && 'opacity-50 italic'">{{ data.reason || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_by" header="Por">
|
||||
<template #body="{ data }"><span class="font-mono text-[0.75rem] opacity-70">{{ data.created_by?.slice(0, 8) || '—' }}</span></template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="dlg.open" :header="`${dlg.nextEnabled ? 'Ativar' : 'Desativar'}: ${dlg.feature?.name || dlg.feature?.key}`" modal :style="{ width: '480px' }" :closable="!saving">
|
||||
<div v-if="dlg.isException" class="rounded-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 mb-3 text-[0.9rem]">
|
||||
<i class="pi pi-exclamation-triangle text-amber-500 mr-2" />
|
||||
Esta feature <b>não está no plano</b> do tenant. Ao confirmar, vai ser registrada como <b>exceção comercial</b>.
|
||||
</div>
|
||||
|
||||
<label class="text-[0.85rem] text-[var(--text-color-secondary)]">Motivo {{ dlg.isException ? '(obrigatório)' : '(opcional)' }}</label>
|
||||
<Textarea v-model="dlg.reason" rows="3" class="w-full mt-1" :placeholder="dlg.isException ? 'Ex: Cliente pagou R$50/mês via PIX em 19/04' : 'Ex: Tenant desligou módulo por preferência'" autoResize />
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text :disabled="saving" @click="dlg.open = false" />
|
||||
<Button :label="dlg.nextEnabled ? 'Ativar' : 'Desativar'" :icon="dlg.nextEnabled ? 'pi pi-check' : 'pi pi-times'" :severity="dlg.isException ? 'info' : dlg.nextEnabled ? 'success' : 'warn'" :loading="saving" @click="confirmChange" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,346 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SaaS / Twilio Config
|
||||
|--------------------------------------------------------------------------
|
||||
| Painel para editar config operacional Twilio (account_sid, webhook URL,
|
||||
| cotação, margem) sem precisar de redeploy.
|
||||
|
|
||||
| AUTH_TOKEN continua em env var por segurança — painel só mostra status.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
import AccordionContent from 'primevue/accordioncontent';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const helpOpen = ref(true);
|
||||
|
||||
const form = ref({
|
||||
account_sid: '',
|
||||
whatsapp_webhook_url: '',
|
||||
usd_brl_rate: 5.5,
|
||||
margin_multiplier: 1.4,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const meta = ref({ updated_at: null, updated_by: null });
|
||||
|
||||
// Status do AUTH_TOKEN: tentamos uma chamada que NÃO mexe em nada (sync com canal inválido)
|
||||
// e checamos se a edge devolve "TWILIO_AUTH_TOKEN não configurado". Se sim, env tá vazia.
|
||||
const tokenStatus = ref('unknown'); // 'configured' | 'missing' | 'unknown'
|
||||
const tokenChecking = ref(false);
|
||||
|
||||
const sidValid = computed(() => !form.value.account_sid || /^AC[a-zA-Z0-9]{32}$/.test(form.value.account_sid));
|
||||
const urlValid = computed(() => !form.value.whatsapp_webhook_url || /^https?:\/\//.test(form.value.whatsapp_webhook_url));
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_twilio_config');
|
||||
if (error) throw error;
|
||||
if (data) {
|
||||
form.value = {
|
||||
account_sid: data.account_sid || '',
|
||||
whatsapp_webhook_url: data.whatsapp_webhook_url || '',
|
||||
usd_brl_rate: Number(data.usd_brl_rate) || 5.5,
|
||||
margin_multiplier: Number(data.margin_multiplier) || 1.4,
|
||||
notes: data.notes || ''
|
||||
};
|
||||
meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha config', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!sidValid.value) {
|
||||
toast.add({ severity: 'warn', summary: 'SID inválido', detail: 'O Account SID deve começar com AC seguido de 32 caracteres.', life: 4000 });
|
||||
return;
|
||||
}
|
||||
if (!urlValid.value) {
|
||||
toast.add({ severity: 'warn', summary: 'URL inválida', detail: 'O webhook deve começar com http:// ou https://', life: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('update_twilio_config', {
|
||||
p_account_sid: form.value.account_sid || null,
|
||||
p_whatsapp_webhook_url: form.value.whatsapp_webhook_url || null,
|
||||
p_usd_brl_rate: form.value.usd_brl_rate,
|
||||
p_margin_multiplier: form.value.margin_multiplier,
|
||||
p_notes: form.value.notes || null
|
||||
});
|
||||
if (error) throw error;
|
||||
if (data) meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada. A edge function lê do banco a cada chamada.', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha ao salvar', life: 4500 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTokenStatus() {
|
||||
tokenChecking.value = true;
|
||||
try {
|
||||
// Chamada inofensiva: action inválida → edge function ainda checa env vars antes
|
||||
// Se devolver "TWILIO_AUTH_TOKEN não configurado" → env vazia
|
||||
// Se devolver outro erro (ex: "action obrigatória" ou "Não autorizado") → env tem valor
|
||||
const { data, error } = await supabase.functions.invoke('twilio-whatsapp-provision', {
|
||||
body: { action: 'sync_usage' }
|
||||
});
|
||||
let msg = '';
|
||||
if (error) {
|
||||
try {
|
||||
const ctxBody = await error.context?.json?.();
|
||||
msg = ctxBody?.error || error.message || '';
|
||||
} catch {
|
||||
msg = error.message || '';
|
||||
}
|
||||
} else if (data?.error) {
|
||||
msg = data.error;
|
||||
}
|
||||
|
||||
if (/AUTH_TOKEN.*não configurado/i.test(msg)) {
|
||||
tokenStatus.value = 'missing';
|
||||
} else {
|
||||
// qualquer outro erro (incl. ACCOUNT_SID, "action", etc) → token está setado
|
||||
tokenStatus.value = 'configured';
|
||||
}
|
||||
} catch {
|
||||
tokenStatus.value = 'unknown';
|
||||
} finally {
|
||||
tokenChecking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenSeverity = computed(() => ({
|
||||
configured: 'success',
|
||||
missing: 'danger',
|
||||
unknown: 'secondary'
|
||||
})[tokenStatus.value]);
|
||||
|
||||
const tokenLabel = computed(() => ({
|
||||
configured: 'configurado ✓',
|
||||
missing: 'não configurado',
|
||||
unknown: 'desconhecido (clique em "Verificar")'
|
||||
})[tokenStatus.value]);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight">Configuração Twilio</div>
|
||||
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Edite a config operacional sem redeploy. O <b>Auth Token</b> (secret) continua em variável de ambiente da Edge Function por segurança.
|
||||
</div>
|
||||
<div v-if="meta.updated_at" class="text-xs text-[var(--text-color-secondary)] mt-2">
|
||||
Última atualização: {{ new Date(meta.updated_at).toLocaleString('pt-BR') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card explicativo -->
|
||||
<div class="rounded-md border border-indigo-400/30 bg-indigo-400/5">
|
||||
<Accordion :value="helpOpen ? '0' : null" @update:value="(v) => helpOpen = (v === '0')">
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-info-circle text-indigo-400" />
|
||||
<span class="font-semibold">Como funciona — guia rápido</span>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-col gap-4 text-[0.92rem] leading-relaxed">
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Onde cada coisa fica</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="rounded-md border border-emerald-400/30 bg-emerald-400/5 p-3">
|
||||
<div class="font-medium text-emerald-300">No banco (editável aqui) ✏️</div>
|
||||
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
|
||||
<li><b>Account SID</b> — identificador público da conta</li>
|
||||
<li><b>Webhook URL</b> — endpoint que recebe callbacks do Twilio</li>
|
||||
<li><b>Cotação USD/BRL</b> — usada nos cálculos de custo</li>
|
||||
<li><b>Multiplicador de margem</b> — markup aplicado ao preço por mensagem</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-3">
|
||||
<div class="font-medium text-amber-300">Em env var (CLI obrigatório) 🔒</div>
|
||||
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
|
||||
<li><b>TWILIO_AUTH_TOKEN</b> — secret que envia mensagens</li>
|
||||
</ul>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-2">
|
||||
Mantido isolado pra reduzir superfície de ataque. Se vazar, atacante manda WhatsApp/SMS na sua conta até estourar limite.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Como configurar do zero</div>
|
||||
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
|
||||
<li>Crie a conta master em <a href="https://www.twilio.com/console" target="_blank" class="underline text-indigo-300">twilio.com/console</a> e copie o <b>Account SID</b> + <b>Auth Token</b>.</li>
|
||||
<li>
|
||||
Configure o secret na Edge Function:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=seu_token_aqui</pre>
|
||||
</li>
|
||||
<li>
|
||||
Cole o Account SID no campo abaixo (formato <code class="px-1 rounded bg-black/30 text-[11px]">AC + 32 chars</code>).
|
||||
</li>
|
||||
<li>
|
||||
Configure o webhook URL apontando pra sua função de webhook:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">https://<projeto>.supabase.co/functions/v1/twilio-whatsapp-webhook</pre>
|
||||
</li>
|
||||
<li>Ajuste cotação USD/BRL e multiplicador de margem conforme sua estratégia comercial.</li>
|
||||
<li>Salve. As Edge Functions vão ler a config nova a cada chamada (sem redeploy).</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Por que o Auth Token não fica aqui</div>
|
||||
<p class="text-[var(--text-color-secondary)]">
|
||||
Decisão de segurança: dados editáveis no painel ficam no banco com RLS estrita (saas_admin). Mas o Auth Token é o único que dá poder de <b>gerar custos reais</b> (envio de SMS/WhatsApp). Mantê-lo isolado em env var significa que mesmo um vazamento do banco (backup público acidental, RLS bug, dump em dev) não compromete o token.
|
||||
</p>
|
||||
<p class="text-[var(--text-color-secondary)] mt-2">
|
||||
O painel mostra apenas <b>se o token está configurado</b> (✓/✗) — sem nunca ler ou expor o valor.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Como testar depois de salvar</div>
|
||||
<ol class="list-decimal pl-6 space-y-1 text-[var(--text-color-secondary)]">
|
||||
<li>Vá em <span class="font-mono text-xs">/saas/twilio-whatsapp</span></li>
|
||||
<li>Clique em "Sincronizar uso" — deve devolver "0 canais atualizados" se ainda não há subcontas, sem erro.</li>
|
||||
<li>Provisione uma subconta de teste pra um tenant.</li>
|
||||
<li>Use o botão de envio de teste pra validar o pipeline completo.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<!-- Status do AUTH_TOKEN -->
|
||||
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-5">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-key text-amber-400" />
|
||||
TWILIO_AUTH_TOKEN (env var)
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
Secret que autentica todas as chamadas pra API do Twilio. Não armazenado no banco.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Tag :value="tokenLabel" :severity="tokenSeverity" />
|
||||
<Button label="Verificar" icon="pi pi-search" size="small" outlined :loading="tokenChecking" @click="checkTokenStatus" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
|
||||
<b>Pra trocar:</b>
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=novo_token</pre>
|
||||
Depois redeploy as Edge Functions afetadas (ou aguarde restart automático).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold mb-4">Configuração editável</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Account SID (formato AC + 32 chars)</label>
|
||||
<InputText
|
||||
v-model="form.account_sid"
|
||||
placeholder="AC..."
|
||||
class="w-full mt-1"
|
||||
:invalid="!sidValid"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
<div v-if="!sidValid" class="text-xs text-rose-400 mt-1">SID precisa começar com AC seguido de 32 caracteres alfanuméricos.</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Webhook URL (callbacks de status do Twilio)</label>
|
||||
<InputText
|
||||
v-model="form.whatsapp_webhook_url"
|
||||
placeholder="https://..."
|
||||
class="w-full mt-1"
|
||||
:invalid="!urlValid"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
<div v-if="!urlValid" class="text-xs text-rose-400 mt-1">URL deve começar com http:// ou https://</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Cotação USD/BRL (1 USD vale)</label>
|
||||
<InputNumber
|
||||
v-model="form.usd_brl_rate"
|
||||
:min="0.01"
|
||||
:max="99.99"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="4"
|
||||
mode="decimal"
|
||||
class="w-full mt-1"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Multiplicador de margem (1.0 = sem margem)</label>
|
||||
<InputNumber
|
||||
v-model="form.margin_multiplier"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="4"
|
||||
mode="decimal"
|
||||
class="w-full mt-1"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Notas internas (não aparece pro tenant)</label>
|
||||
<Textarea
|
||||
v-model="form.notes"
|
||||
rows="3"
|
||||
class="w-full mt-1"
|
||||
autoResize
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<Button label="Recarregar" icon="pi pi-refresh" outlined :disabled="saving" :loading="loading" @click="loadConfig" />
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="saving" :disabled="!sidValid || !urlValid" @click="saveConfig" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -234,7 +234,12 @@ async function syncAll() {
|
||||
const result = await store.syncUsageAll();
|
||||
toast.add({ severity: 'success', summary: 'Consumo sincronizado', detail: `${result.synced?.length ?? 0} canal(is) atualizados`, life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Não foi possível sincronizar',
|
||||
detail: e.message,
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ import Dropdown from 'primevue/dropdown';
|
||||
import Textarea from 'primevue/textarea';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const TAG = 'SubscriptionIntentsPage';
|
||||
|
||||
import { listSubscriptionIntents, markIntentPaid, cancelIntent, getSubscriptionForIntent } from '@/services/subscriptionIntents';
|
||||
|
||||
@@ -229,7 +232,7 @@ async function refresh() {
|
||||
});
|
||||
lastRefreshAt.value = new Date().toISOString();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'refresh', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -375,7 +378,7 @@ async function confirmAction() {
|
||||
showDialog.value = false;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'confirmAction', e);
|
||||
|
||||
const msg = e?.message || 'Falha ao atualizar.';
|
||||
const msgLower = String(msg).toLowerCase();
|
||||
@@ -449,7 +452,7 @@ async function openSubscriptionDialogFromIntent(intentRow) {
|
||||
subNotes.value = '';
|
||||
showSubDialog.value = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'openSubscriptionDialog', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar assinatura.', life: 4500 });
|
||||
} finally {
|
||||
acting.value = false;
|
||||
@@ -547,7 +550,7 @@ async function confirmSubAction(toStatus) {
|
||||
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'confirmSubAction', e);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevAuditoriaTab.vue — Bugs/débitos técnicos (CRUD + drag-drop)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<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 ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import { useDraggableList, reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const items = ref([]);
|
||||
const filterStatus = ref('all');
|
||||
const filterSev = ref('all');
|
||||
const search = ref('');
|
||||
const openRow = ref(null);
|
||||
|
||||
// Drawer state
|
||||
const drawerOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
categoria: '',
|
||||
titulo: '',
|
||||
descricao_problema: '',
|
||||
solucao: '',
|
||||
severidade: 'medio',
|
||||
status: 'aberto',
|
||||
resolvido_em: null,
|
||||
sessao_resolucao: '',
|
||||
arquivo_afetado: '',
|
||||
tags: ''
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
aberto: { label: 'Aberto', color: '#ef4444', bg: 'rgba(239,68,68,.12)', icon: 'pi-exclamation-circle' },
|
||||
em_analise: { label: 'Em análise', color: '#f59e0b', bg: 'rgba(245,158,11,.12)', icon: 'pi-eye' },
|
||||
resolvido: { label: 'Resolvido', color: '#10b981', bg: 'rgba(16,185,129,.12)', icon: 'pi-check-circle' },
|
||||
wontfix: { label: "Won't fix", color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-ban' },
|
||||
duplicado: { label: 'Duplicado', color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-clone' }
|
||||
};
|
||||
|
||||
const SEV_LABEL = {
|
||||
critico: { label: 'Crítico', color: '#dc2626' },
|
||||
alto: { label: 'Alto', color: '#f59e0b' },
|
||||
medio: { label: 'Médio', color: '#0ea5e9' },
|
||||
baixo: { label: 'Baixo', color: '#94a3b8' }
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('dev_auditoria_items')
|
||||
.select('*')
|
||||
.order('ordem')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
items.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value;
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterSev.value !== 'all') list = list.filter((i) => i.severidade === filterSev.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.titulo.toLowerCase().includes(q) ||
|
||||
(i.descricao_problema || '').toLowerCase().includes(q) ||
|
||||
(i.categoria || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const counts = computed(() => ({
|
||||
total: items.value.length,
|
||||
aberto: items.value.filter((i) => ['aberto', 'em_analise'].includes(i.status)).length,
|
||||
resolvido: items.value.filter((i) => i.status === 'resolvido').length,
|
||||
critico: items.value.filter((i) => i.severidade === 'critico' && ['aberto', 'em_analise'].includes(i.status)).length
|
||||
}));
|
||||
|
||||
function toggle(id) {
|
||||
openRow.value = openRow.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editingId.value = item.id;
|
||||
form.value = {
|
||||
categoria: item.categoria || '',
|
||||
titulo: item.titulo || '',
|
||||
descricao_problema: item.descricao_problema || '',
|
||||
solucao: item.solucao || '',
|
||||
severidade: item.severidade || 'medio',
|
||||
status: item.status || 'aberto',
|
||||
resolvido_em: item.resolvido_em || null,
|
||||
sessao_resolucao: item.sessao_resolucao || '',
|
||||
arquivo_afetado: item.arquivo_afetado || '',
|
||||
tags: (item.tags || []).join(', ')
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.titulo.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Título obrigatório', life: 3000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
categoria: form.value.categoria.trim() || null,
|
||||
titulo: form.value.titulo.trim(),
|
||||
descricao_problema: form.value.descricao_problema.trim() || null,
|
||||
solucao: form.value.solucao.trim() || null,
|
||||
severidade: form.value.severidade || null,
|
||||
status: form.value.status,
|
||||
resolvido_em: form.value.resolvido_em || null,
|
||||
sessao_resolucao: form.value.sessao_resolucao.trim() || null,
|
||||
arquivo_afetado: form.value.arquivo_afetado.trim() || null,
|
||||
tags: form.value.tags
|
||||
? form.value.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
const { error } = await supabase.from('dev_auditoria_items').update(payload).eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (items.value.length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_auditoria_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
if (!editingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Tem certeza que quer excluir este item?',
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_auditoria_items').delete().eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorder(fromIdx, toIdx) {
|
||||
// Reordena apenas se não houver filtro ativo (senão os índices não batem com o DB)
|
||||
const hasFilter = filterStatus.value !== 'all' || filterSev.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
detail: 'Drag-drop só funciona com a lista completa.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
const before = items.value;
|
||||
items.value = reorderWithIndexes(before, fromIdx, toIdx);
|
||||
try {
|
||||
const updates = items.value.map((item) =>
|
||||
supabase.from('dev_auditoria_items').update({ ordem: item.ordem }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
} catch (e) {
|
||||
items.value = before; // rollback
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
const drag = useDraggableList({ onReorder: handleReorder });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="auditoria">
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">{{ counts.total }}</span>
|
||||
<span class="stat-lbl">total</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #ef4444">
|
||||
<span class="stat-num">{{ counts.aberto }}</span>
|
||||
<span class="stat-lbl">em aberto</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #10b981">
|
||||
<span class="stat-num">{{ counts.resolvido }}</span>
|
||||
<span class="stat-lbl">resolvidos</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #dc2626">
|
||||
<span class="stat-num">{{ counts.critico }}</span>
|
||||
<span class="stat-lbl">críticos abertos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar bug/débito..." class="filter-search" />
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="aberto">Aberto</option>
|
||||
<option value="em_analise">Em análise</option>
|
||||
<option value="resolvido">Resolvido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
<option value="duplicado">Duplicado</option>
|
||||
</select>
|
||||
<select v-model="filterSev" class="filter-sel">
|
||||
<option value="all">Todas severidades</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNew">
|
||||
<i class="pi pi-plus" /> Novo item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item com os filtros atuais.
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<article
|
||||
v-for="(item, idx) in itemsFiltered"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'audit-item',
|
||||
{ open: openRow === item.id, dragging: drag.dragIdx.value === idx, 'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx }
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<header class="audit-head">
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
<i
|
||||
:class="['pi', STATUS_LABEL[item.status]?.icon || 'pi-circle']"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color }"
|
||||
/>
|
||||
<div class="audit-title-wrap" @click="toggle(item.id)">
|
||||
<h4 class="audit-title">
|
||||
<span class="audit-ref">A#{{ item.id }}</span>
|
||||
{{ item.titulo }}
|
||||
</h4>
|
||||
<div class="audit-meta">
|
||||
<span v-if="item.categoria" class="audit-cat">{{ item.categoria }}</span>
|
||||
<span v-if="item.severidade" :style="{ color: SEV_LABEL[item.severidade]?.color }">
|
||||
{{ SEV_LABEL[item.severidade]?.label }}
|
||||
</span>
|
||||
<span v-if="item.arquivo_afetado" class="audit-file">
|
||||
<i class="pi pi-file text-xs" /> {{ item.arquivo_afetado }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="audit-status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="openEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<i
|
||||
:class="['pi pi-chevron-down audit-chev', { open: openRow === item.id }]"
|
||||
@click="toggle(item.id)"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div v-if="openRow === item.id" class="audit-body">
|
||||
<div v-if="item.descricao_problema" class="audit-section">
|
||||
<h5>Problema</h5>
|
||||
<p>{{ item.descricao_problema }}</p>
|
||||
</div>
|
||||
<div v-if="item.solucao" class="audit-section">
|
||||
<h5>Solução</h5>
|
||||
<p>{{ item.solucao }}</p>
|
||||
</div>
|
||||
<div v-if="item.sessao_resolucao || item.resolvido_em" class="audit-section">
|
||||
<h5>Resolução</h5>
|
||||
<p>
|
||||
<span v-if="item.sessao_resolucao">{{ item.sessao_resolucao }}</span>
|
||||
<span v-if="item.resolvido_em" class="text-[var(--text-color-secondary)]">
|
||||
— {{ formatDate(item.resolvido_em) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="audit-tags">
|
||||
<span v-for="tag in item.tags" :key="tag" class="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de edição -->
|
||||
<DevDrawer
|
||||
:open="drawerOpen"
|
||||
:title="editingId ? 'Editar bug/débito' : 'Novo bug/débito'"
|
||||
:subtitle="editingId ? `#${editingId}` : 'Preencha os campos e salve'"
|
||||
:can-save="!!form.titulo.trim()"
|
||||
:saving="saving"
|
||||
:danger="!!editingId"
|
||||
@close="drawerOpen = false"
|
||||
@save="save"
|
||||
@delete="askDelete"
|
||||
>
|
||||
<DevField label="Título" required>
|
||||
<input v-model="form.titulo" placeholder="Ex.: Memory leak no useRecurrence" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Status">
|
||||
<select v-model="form.status">
|
||||
<option value="aberto">Aberto</option>
|
||||
<option value="em_analise">Em análise</option>
|
||||
<option value="resolvido">Resolvido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
<option value="duplicado">Duplicado</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Severidade">
|
||||
<select v-model="form.severidade">
|
||||
<option value="">—</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Categoria" hint="Ex.: Bug crítico, Dívida técnica, Performance, Arquitetura, Segurança">
|
||||
<input v-model="form.categoria" placeholder="Categoria" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Descrição do problema" hint="O que tá errado, onde acontece, como reproduzir">
|
||||
<textarea v-model="form.descricao_problema" rows="4" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Solução" hint="Como foi/será resolvido">
|
||||
<textarea v-model="form.solucao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Data de resolução">
|
||||
<input v-model="form.resolvido_em" type="date" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Sessão de resolução" hint="Ex.: Sessão 3 — 2026-03-11">
|
||||
<input v-model="form.sessao_resolucao" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Arquivo afetado" hint="src/features/agenda/...">
|
||||
<input v-model="form.arquivo_afetado" placeholder="Caminho do arquivo" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Tags" hint="Separe por vírgula. Ex.: agenda, recorrência, n+1">
|
||||
<input v-model="form.tags" placeholder="tag1, tag2, tag3" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.auditoria { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-left: 3px solid var(--c, var(--primary-color));
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-lbl {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 200px; }
|
||||
.filter-sel { min-width: 160px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.audit-item {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.audit-item:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.audit-item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.audit-item.drop-target { border-color: var(--primary-color); border-style: dashed; background: color-mix(in srgb, var(--primary-color) 5%, transparent); }
|
||||
|
||||
.audit-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.drag-handle:hover { opacity: 1; background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.audit-head > .pi:nth-child(2) { font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.audit-title-wrap { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.audit-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.audit-ref {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
.audit-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.audit-cat {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.audit-file {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.audit-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.audit-chev {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.audit-chev.open { transform: rotate(180deg); }
|
||||
|
||||
.audit-body {
|
||||
padding: 0 14px 14px 42px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.audit-section { margin-bottom: 10px; }
|
||||
.audit-section h5 {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.audit-section p {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.audit-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevDatabaseTab.vue — comandos do db.cjs via copy-to-clipboard
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const toast = useToast();
|
||||
const recentLogs = ref([]);
|
||||
|
||||
const commands = [
|
||||
{
|
||||
group: 'Operação diária',
|
||||
items: [
|
||||
{
|
||||
label: 'Backup completo',
|
||||
cmd: 'cd database-novo && node db.cjs backup',
|
||||
desc: 'Gera schema.sql + data.sql + full_dump.sql + supabase_restore.sql em backups/YYYY-MM-DD/',
|
||||
icon: 'pi-save'
|
||||
},
|
||||
{
|
||||
label: 'Status do banco',
|
||||
cmd: 'cd database-novo && node db.cjs status',
|
||||
desc: 'Mostra estado: container rodando, último backup, migrations aplicadas, contagens',
|
||||
icon: 'pi-chart-bar'
|
||||
},
|
||||
{
|
||||
label: 'Verify (integridade)',
|
||||
cmd: 'cd database-novo && node db.cjs verify',
|
||||
desc: 'Checa tabelas e views essenciais (definidas em db.config.json → verify)',
|
||||
icon: 'pi-check-circle'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Dashboards e exports',
|
||||
items: [
|
||||
{
|
||||
label: 'Gerar dashboard HTML do banco',
|
||||
cmd: 'cd database-novo && node db.cjs dashboard',
|
||||
desc: 'Regenera agenciapsi-db-dashboard.html com domínios + infraestrutura + busca',
|
||||
icon: 'pi-th-large'
|
||||
},
|
||||
{
|
||||
label: 'Schema export (00_full..10_grants)',
|
||||
cmd: 'cd database-novo && node db.cjs schema-export',
|
||||
desc: 'Atualiza as 11 pastas schema/ a partir do estado atual do banco',
|
||||
icon: 'pi-file-export'
|
||||
},
|
||||
{
|
||||
label: 'Diff vs último backup',
|
||||
cmd: 'cd database-novo && node db.cjs diff',
|
||||
desc: 'Lista tabelas novas, alteradas e removidas desde o último backup',
|
||||
icon: 'pi-compare'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Migrations e seeds',
|
||||
items: [
|
||||
{
|
||||
label: 'Aplicar migrations pendentes',
|
||||
cmd: 'cd database-novo && node db.cjs migrate',
|
||||
desc: 'Aplica migrations em database-novo/migrations/ ainda não aplicadas. Faz backup antes.',
|
||||
icon: 'pi-forward'
|
||||
},
|
||||
{
|
||||
label: 'Rodar todos os seeds',
|
||||
cmd: 'cd database-novo && node db.cjs seed all',
|
||||
desc: 'Roda seeds dos grupos users + system',
|
||||
icon: 'pi-database'
|
||||
},
|
||||
{
|
||||
label: 'Seed: system (inclui dev_*)',
|
||||
cmd: 'cd database-novo && node db.cjs seed system',
|
||||
desc: 'Popula tabelas do sistema (planos, features, templates + dev_phases/items/auditoria/competitors)',
|
||||
icon: 'pi-cog'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Operações críticas',
|
||||
items: [
|
||||
{
|
||||
label: 'Restore (último backup)',
|
||||
cmd: 'cd database-novo && node db.cjs restore',
|
||||
desc: 'Restaura do backup mais recente. Prioriza supabase_restore.sql.',
|
||||
icon: 'pi-history',
|
||||
danger: true
|
||||
},
|
||||
{
|
||||
label: 'Reset (DROP + setup)',
|
||||
cmd: 'cd database-novo && node db.cjs reset',
|
||||
desc: 'DROP schema public + reinstala tudo. Faz backup antes.',
|
||||
icon: 'pi-refresh',
|
||||
danger: true
|
||||
},
|
||||
{
|
||||
label: 'Setup do zero',
|
||||
cmd: 'cd database-novo && node db.cjs setup',
|
||||
desc: 'Primeira instalação: schema + fixes + seeds + migrations + backup + verify',
|
||||
icon: 'pi-bolt',
|
||||
danger: true
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('dev_generation_log')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
recentLogs.value = data || [];
|
||||
} catch (e) {
|
||||
logError('DevDatabaseTab', 'loadRecentLogs', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(cmd) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Copiado!',
|
||||
detail: 'Cole no terminal e execute.',
|
||||
life: 2500
|
||||
});
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Falha ao copiar',
|
||||
detail: 'Copie manualmente — clipboard indisponível.',
|
||||
life: 4000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
onMounted(loadLogs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="db-tab">
|
||||
<div class="notice">
|
||||
<i class="pi pi-info-circle" />
|
||||
<div>
|
||||
<strong>Copy-to-clipboard mode.</strong>
|
||||
Os comandos precisam ser executados no terminal (na raiz do projeto). Na Parte C futuro vai ter um CLI server local que executa direto daqui.
|
||||
<br />
|
||||
<span class="text-[var(--text-color-secondary)]">
|
||||
O container Supabase precisa estar rodando (<code>npx supabase start</code>) antes de qualquer comando.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="group in commands" :key="group.group" class="cmd-group">
|
||||
<h3 class="cmd-group-title">{{ group.group }}</h3>
|
||||
<div class="cmd-list">
|
||||
<article v-for="cmd in group.items" :key="cmd.label" :class="['cmd-card', { danger: cmd.danger }]">
|
||||
<div class="cmd-head">
|
||||
<div class="cmd-icon">
|
||||
<i :class="['pi', cmd.icon]" />
|
||||
</div>
|
||||
<div class="cmd-title-wrap">
|
||||
<h4 class="cmd-title">
|
||||
{{ cmd.label }}
|
||||
<span v-if="cmd.danger" class="danger-chip">CUIDADO</span>
|
||||
</h4>
|
||||
<p class="cmd-desc">{{ cmd.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cmd-action">
|
||||
<code class="cmd-text">{{ cmd.cmd }}</code>
|
||||
<button class="cmd-copy" @click="copyToClipboard(cmd.cmd)">
|
||||
<i class="pi pi-copy" /> Copiar
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico -->
|
||||
<div class="cmd-group">
|
||||
<h3 class="cmd-group-title">Últimas execuções registradas</h3>
|
||||
<div v-if="!recentLogs.length" class="empty">Nenhum log ainda.</div>
|
||||
<ul v-else class="log-list">
|
||||
<li v-for="log in recentLogs" :key="log.id" class="log-item">
|
||||
<i
|
||||
:class="['pi', log.sucesso ? 'pi-check-circle' : 'pi-times-circle']"
|
||||
:style="{ color: log.sucesso ? '#10b981' : '#ef4444' }"
|
||||
/>
|
||||
<span class="log-tipo">{{ log.tipo }}</span>
|
||||
<span class="log-cmd">{{ log.comando || '—' }}</span>
|
||||
<span class="log-date">{{ formatDate(log.created_at) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.db-tab { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.notice i { color: var(--primary-color); font-size: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||
.notice strong { color: var(--primary-color); }
|
||||
.notice code { font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
|
||||
|
||||
.cmd-group {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.cmd-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.cmd-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.cmd-card {
|
||||
background: var(--surface-ground, #f8fafc);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cmd-card:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.cmd-card.danger { border-left: 3px solid #ef4444; }
|
||||
|
||||
.cmd-head { display: flex; gap: 10px; margin-bottom: 8px; }
|
||||
.cmd-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmd-card.danger .cmd-icon {
|
||||
background: rgba(239,68,68,.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.cmd-title-wrap { flex: 1; min-width: 0; }
|
||||
|
||||
.cmd-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.danger-chip {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
background: rgba(239,68,68,.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.cmd-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.cmd-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #0b0d12;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.cmd-text {
|
||||
flex: 1;
|
||||
font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
color: #e2e8f8;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.cmd-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cmd-copy:hover { opacity: 0.9; }
|
||||
.cmd-copy:active { transform: scale(0.97); }
|
||||
|
||||
.log-list { list-style: none; margin: 0; padding: 0; }
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.log-item:last-child { border-bottom: none; }
|
||||
.log-tipo {
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.log-cmd {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-date { font-size: 11px; color: var(--text-color-secondary); }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevEstruturaTab.vue — ESTRUTURA.md + mapa-sistema.html embarcados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import estruturaRaw from '@/../development/01-visao-geral/ESTRUTURA.md?raw';
|
||||
import mapaSistemaHtml from '@/../development/01-visao-geral/mapa-sistema.html?raw';
|
||||
|
||||
const viewMode = ref('tree'); // 'tree' | 'mapa'
|
||||
const content = ref(estruturaRaw);
|
||||
|
||||
// Gerar blob URL do mapa-sistema.html pra embedar no iframe
|
||||
const mapaUrl = computed(() => {
|
||||
const blob = new Blob([mapaSistemaHtml], { type: 'text/html' });
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="estrutura">
|
||||
<div class="view-toggle">
|
||||
<button :class="['toggle-btn', { active: viewMode === 'tree' }]" @click="viewMode = 'tree'">
|
||||
<i class="pi pi-list" /> Árvore (ESTRUTURA.md)
|
||||
</button>
|
||||
<button :class="['toggle-btn', { active: viewMode === 'mapa' }]" @click="viewMode = 'mapa'">
|
||||
<i class="pi pi-sitemap" /> Mapa interativo
|
||||
</button>
|
||||
<a
|
||||
href="/development/01-visao-geral/mapa-sistema.html"
|
||||
target="_blank"
|
||||
class="external-link"
|
||||
title="Abrir em nova aba"
|
||||
>
|
||||
<i class="pi pi-external-link" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="viewMode === 'tree'" class="md-container">
|
||||
<pre class="md-content">{{ content }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-else class="mapa-container">
|
||||
<iframe :src="mapaUrl" class="mapa-frame" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.estrutura {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.external-link {
|
||||
margin-left: auto;
|
||||
padding: 7px 10px;
|
||||
color: var(--text-color-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.external-link:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.md-container {
|
||||
background: #0b0d12;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-content {
|
||||
margin: 0;
|
||||
padding: 20px 24px;
|
||||
color: #e2e8f8;
|
||||
font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-x: auto;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mapa-container {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
min-height: 800px;
|
||||
height: 85vh;
|
||||
}
|
||||
|
||||
.mapa-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevExportTab.vue — Exportar banco → .md (stub read-only por ora)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
function notImplemented() {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Pendente — Parte C',
|
||||
detail: 'Geração de .md a partir do banco será implementada na próxima etapa.',
|
||||
life: 4000
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="export-tab">
|
||||
<div class="notice">
|
||||
<i class="pi pi-info-circle" />
|
||||
<div>
|
||||
<strong>Área de exportação — pendente na Parte C</strong>
|
||||
<p>
|
||||
Nesta aba você vai poder regenerar os arquivos <code>.md</code> em <code>development/</code> a partir dos dados do banco (banco vira source-of-truth, arquivo vira artefato versionável).
|
||||
</p>
|
||||
<p class="text-[var(--text-color-secondary)]" style="margin-top: 6px">
|
||||
Detalhes do que falta estão em <code>development/PENDENTE.md</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-grid">
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-flag" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>ROADMAP.md</h3>
|
||||
<p>Regenera o arquivo roadmap a partir de <code>dev_roadmap_phases</code> + <code>dev_roadmap_items</code>.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-verified" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>AUDITORIA.md</h3>
|
||||
<p>Regenera a auditoria técnica a partir de <code>dev_auditoria_items</code>.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-globe" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>concorrentes.md</h3>
|
||||
<p>Regenera o benchmark a partir de <code>dev_competitors</code> + <code>dev_competitor_features</code> + <code>dev_comparison_matrix</code>.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-sitemap" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>ESTRUTURA.md</h3>
|
||||
<p>Regenera o snapshot da estrutura — hoje é estático, futuro será derivado do menu + banco.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-tab { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
background: color-mix(in srgb, #f59e0b 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.notice i { color: #f59e0b; font-size: 18px; flex-shrink: 0; margin-top: 1px; }
|
||||
.notice strong { color: #d97706; display: block; margin-bottom: 6px; }
|
||||
.notice p { line-height: 1.5; margin: 0; }
|
||||
.notice code { font-family: 'IBM Plex Mono', monospace; font-size: 11px; background: rgba(0,0,0,.05); padding: 1px 5px; border-radius: 3px; }
|
||||
|
||||
.export-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.export-card {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.export-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.export-body h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0 0 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.export-body p {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.export-body code {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.export-btn:disabled {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevOverviewTab.vue — Visão Geral da área de desenvolvimento
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const loading = ref(true);
|
||||
const stats = ref({
|
||||
phases: [],
|
||||
roadmapTotal: 0,
|
||||
roadmapConcluido: 0,
|
||||
roadmapEmAndamento: 0,
|
||||
roadmapPendente: 0,
|
||||
auditoriaAberto: 0,
|
||||
auditoriaResolvido: 0,
|
||||
auditoriaTotal: 0,
|
||||
competitors: 0,
|
||||
competitorFeatures: 0,
|
||||
comparisonGaps: 0,
|
||||
comparisonTem: 0,
|
||||
comparisonParcial: 0,
|
||||
lastGenerations: []
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [phasesRes, itemsRes, auditoriaRes, competitorsRes, featuresRes, matrixRes, logRes] = await Promise.all([
|
||||
supabase.from('dev_roadmap_phases').select('*').order('ordem'),
|
||||
supabase.from('dev_roadmap_items').select('id, phase_id, status'),
|
||||
supabase.from('dev_auditoria_items').select('id, status'),
|
||||
supabase.from('dev_competitors').select('id, nome, pais', { count: 'exact' }),
|
||||
supabase.from('dev_competitor_features').select('id', { count: 'exact', head: true }),
|
||||
supabase.from('dev_comparison_matrix').select('id, nosso_status'),
|
||||
supabase.from('dev_generation_log').select('*').order('created_at', { ascending: false }).limit(5)
|
||||
]);
|
||||
|
||||
// Phases with item counts per status
|
||||
const items = itemsRes.data || [];
|
||||
stats.value.phases = (phasesRes.data || []).map((p) => {
|
||||
const phaseItems = items.filter((i) => i.phase_id === p.id);
|
||||
return {
|
||||
...p,
|
||||
total: phaseItems.length,
|
||||
concluido: phaseItems.filter((i) => i.status === 'concluido').length,
|
||||
em_andamento: phaseItems.filter((i) => i.status === 'em_andamento').length,
|
||||
pendente: phaseItems.filter((i) => i.status === 'pendente').length
|
||||
};
|
||||
});
|
||||
|
||||
stats.value.roadmapTotal = items.length;
|
||||
stats.value.roadmapConcluido = items.filter((i) => i.status === 'concluido').length;
|
||||
stats.value.roadmapEmAndamento = items.filter((i) => i.status === 'em_andamento').length;
|
||||
stats.value.roadmapPendente = items.filter((i) => i.status === 'pendente').length;
|
||||
|
||||
const aud = auditoriaRes.data || [];
|
||||
stats.value.auditoriaTotal = aud.length;
|
||||
stats.value.auditoriaAberto = aud.filter((i) => ['aberto', 'em_analise'].includes(i.status)).length;
|
||||
stats.value.auditoriaResolvido = aud.filter((i) => i.status === 'resolvido').length;
|
||||
|
||||
stats.value.competitors = competitorsRes.count || (competitorsRes.data || []).length;
|
||||
stats.value.competitorFeatures = featuresRes.count || 0;
|
||||
|
||||
const matrix = matrixRes.data || [];
|
||||
stats.value.comparisonGaps = matrix.filter((m) => m.nosso_status === 'gap').length;
|
||||
stats.value.comparisonTem = matrix.filter((m) => m.nosso_status === 'tem').length;
|
||||
stats.value.comparisonParcial = matrix.filter((m) => m.nosso_status === 'parcial').length;
|
||||
|
||||
stats.value.lastGenerations = logRes.data || [];
|
||||
} catch (e) {
|
||||
logError('DevOverviewTab', 'loadStats', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function percent(done, total) {
|
||||
if (!total) return 0;
|
||||
return Math.round((done / total) * 100);
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
return {
|
||||
planejada: 'var(--text-color-secondary)',
|
||||
em_andamento: '#f59e0b',
|
||||
concluida: '#10b981',
|
||||
arquivada: '#94a3b8'
|
||||
}[status] || 'var(--text-color-secondary)';
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
onMounted(loadStats);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mt-2">Carregando estatísticas...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overview">
|
||||
<!-- Cards de stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #6366f1 12%, transparent); color: #6366f1">
|
||||
<i class="pi pi-flag" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.roadmapTotal }}</div>
|
||||
<div class="stat-label">features no roadmap</div>
|
||||
<div class="stat-sub">
|
||||
<span class="done">{{ stats.roadmapConcluido }} concluídas</span> ·
|
||||
<span class="doing">{{ stats.roadmapEmAndamento }} em andamento</span> ·
|
||||
<span class="pending">{{ stats.roadmapPendente }} pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #ef4444 12%, transparent); color: #ef4444">
|
||||
<i class="pi pi-verified" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.auditoriaAberto }}</div>
|
||||
<div class="stat-label">bugs/débitos abertos</div>
|
||||
<div class="stat-sub">
|
||||
<span>{{ stats.auditoriaResolvido }} resolvidos</span> ·
|
||||
<span>{{ stats.auditoriaTotal }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #0ea5e9 12%, transparent); color: #0ea5e9">
|
||||
<i class="pi pi-globe" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.competitors }}</div>
|
||||
<div class="stat-label">concorrentes analisados</div>
|
||||
<div class="stat-sub">{{ stats.competitorFeatures }} features catalogadas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #f59e0b 12%, transparent); color: #f59e0b">
|
||||
<i class="pi pi-exclamation-triangle" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.comparisonGaps }}</div>
|
||||
<div class="stat-label">gaps vs mercado</div>
|
||||
<div class="stat-sub">
|
||||
{{ stats.comparisonTem }} temos · {{ stats.comparisonParcial }} parciais
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progresso das fases -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Progresso das Fases</h2>
|
||||
<div class="phases-grid">
|
||||
<article v-for="phase in stats.phases" :key="phase.id" class="phase-card">
|
||||
<header class="phase-head">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="phase-num">{{ phase.numero }}</span>
|
||||
<h3 class="phase-name">{{ phase.nome }}</h3>
|
||||
</div>
|
||||
<span class="phase-status" :style="{ color: statusColor(phase.status) }">
|
||||
{{ phase.status.replace('_', ' ') }}
|
||||
</span>
|
||||
</header>
|
||||
<p class="phase-obj">{{ phase.objetivo }}</p>
|
||||
<div class="phase-progress">
|
||||
<div class="phase-bar">
|
||||
<div class="phase-fill" :style="{ width: percent(phase.concluido, phase.total) + '%' }" />
|
||||
</div>
|
||||
<span class="phase-perc">{{ percent(phase.concluido, phase.total) }}%</span>
|
||||
</div>
|
||||
<div class="phase-counts">
|
||||
<span class="pc-done">{{ phase.concluido }} concluídos</span>
|
||||
<span class="pc-doing">{{ phase.em_andamento }} em andamento</span>
|
||||
<span class="pc-pending">{{ phase.pendente }} pendentes</span>
|
||||
<span class="pc-total">{{ phase.total }} total</span>
|
||||
</div>
|
||||
<div class="phase-timeline">
|
||||
<i class="pi pi-clock text-xs" />
|
||||
<span>{{ phase.timeline_sugerida || '—' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Últimas gerações -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Últimas gerações (backup, dashboard, seed)</h2>
|
||||
<div v-if="stats.lastGenerations.length === 0" class="empty">Nenhuma geração registrada ainda.</div>
|
||||
<ul v-else class="gen-list">
|
||||
<li v-for="g in stats.lastGenerations" :key="g.id" class="gen-item">
|
||||
<i
|
||||
:class="['pi', g.sucesso ? 'pi-check-circle' : 'pi-times-circle']"
|
||||
:style="{ color: g.sucesso ? '#10b981' : '#ef4444' }"
|
||||
/>
|
||||
<span class="gen-tipo">{{ g.tipo }}</span>
|
||||
<span class="gen-cmd">{{ g.comando || '—' }}</span>
|
||||
<span class="gen-date">{{ formatDate(g.created_at) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.stat-sub .done { color: #10b981; }
|
||||
.stat-sub .doing { color: #f59e0b; }
|
||||
.stat-sub .pending { color: var(--text-color-secondary); }
|
||||
|
||||
.section {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.phases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phase-card {
|
||||
background: var(--surface-ground, #f8fafc);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.phase-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.phase-num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.phase-status {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phase-obj {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.phase-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.phase-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--surface-border, #e2e8f0);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #10b981, #6366f1);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.phase-perc {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.phase-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pc-done { color: #10b981; }
|
||||
.pc-doing { color: #f59e0b; }
|
||||
.pc-pending { color: var(--text-color-secondary); }
|
||||
.pc-total { color: var(--text-color-secondary); margin-left: auto; }
|
||||
|
||||
.phase-timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gen-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gen-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.gen-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gen-tipo {
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gen-cmd {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gen-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,794 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevRoadmapTab.vue — Fases + Items (CRUD + drag-drop + editar fase)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<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 ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import BlocoItems from './components/BlocoItems.vue';
|
||||
import { reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const phases = ref([]);
|
||||
const items = ref([]);
|
||||
const activePhase = ref(null);
|
||||
const filterStatus = ref('all');
|
||||
const filterPrior = ref('all');
|
||||
const search = ref('');
|
||||
|
||||
// Drawer item
|
||||
const itemDrawerOpen = ref(false);
|
||||
const itemSaving = ref(false);
|
||||
const itemEditingId = ref(null);
|
||||
const itemForm = ref(emptyItemForm());
|
||||
|
||||
// Drawer phase
|
||||
const phaseDrawerOpen = ref(false);
|
||||
const phaseSaving = ref(false);
|
||||
const phaseEditingId = ref(null);
|
||||
const phaseForm = ref(emptyPhaseForm());
|
||||
|
||||
function emptyItemForm() {
|
||||
return {
|
||||
phase_id: null,
|
||||
numero: null,
|
||||
bloco: '',
|
||||
feature: '',
|
||||
descricao: '',
|
||||
esforco: 'M',
|
||||
prioridade: 'alta',
|
||||
status: 'pendente',
|
||||
notas: '',
|
||||
assignee: '',
|
||||
data_inicio: null,
|
||||
data_conclusao: null
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPhaseForm() {
|
||||
return {
|
||||
numero: null,
|
||||
nome: '',
|
||||
objetivo: '',
|
||||
timeline_sugerida: '',
|
||||
criterio_saida: '',
|
||||
status: 'planejada',
|
||||
data_inicio: null,
|
||||
data_fim: null
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pendente: { label: 'Pendente', color: 'var(--text-color-secondary)', bg: 'var(--surface-border)' },
|
||||
em_andamento: { label: 'Em andamento', color: '#f59e0b', bg: 'rgba(245,158,11,.15)' },
|
||||
concluido: { label: 'Concluído', color: '#10b981', bg: 'rgba(16,185,129,.15)' },
|
||||
cancelado: { label: 'Cancelado', color: '#94a3b8', bg: 'rgba(148,163,184,.15)' },
|
||||
bloqueado: { label: 'Bloqueado', color: '#ef4444', bg: 'rgba(239,68,68,.15)' }
|
||||
};
|
||||
|
||||
const PRIOR_LABEL = {
|
||||
bloqueador: { label: 'Bloqueador', color: '#ef4444' },
|
||||
alta: { label: 'Alta', color: '#f59e0b' },
|
||||
media: { label: 'Média', color: '#0ea5e9' },
|
||||
diferencial: { label: 'Diferencial', color: '#a855f7' }
|
||||
};
|
||||
|
||||
const ESFORCO_LABEL = {
|
||||
S: { label: 'S', desc: '<1 semana', color: '#10b981' },
|
||||
M: { label: 'M', desc: '1-3 semanas', color: '#0ea5e9' },
|
||||
L: { label: 'L', desc: '3-8 semanas', color: '#f59e0b' },
|
||||
XL: { label: 'XL', desc: '2+ meses', color: '#ef4444' }
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [pRes, iRes] = await Promise.all([
|
||||
supabase.from('dev_roadmap_phases').select('*').order('ordem'),
|
||||
supabase.from('dev_roadmap_items').select('*').order('phase_id').order('ordem')
|
||||
]);
|
||||
if (pRes.error) throw pRes.error;
|
||||
if (iRes.error) throw iRes.error;
|
||||
phases.value = pRes.data || [];
|
||||
items.value = iRes.data || [];
|
||||
if (phases.value.length && activePhase.value === null) {
|
||||
activePhase.value = phases.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const activePhaseObj = computed(() => phases.value.find((p) => p.id === activePhase.value));
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value.filter((i) => i.phase_id === activePhase.value);
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterPrior.value !== 'all') list = list.filter((i) => i.prioridade === filterPrior.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.feature.toLowerCase().includes(q) ||
|
||||
(i.descricao || '').toLowerCase().includes(q) ||
|
||||
(i.bloco || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const itemsByBloco = computed(() => {
|
||||
const groups = {};
|
||||
for (const item of itemsFiltered.value) {
|
||||
const key = item.bloco || 'Sem categoria';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
function countByStatus(phaseId, status) {
|
||||
return items.value.filter((i) => i.phase_id === phaseId && i.status === status).length;
|
||||
}
|
||||
|
||||
// ── CRUD item ────────────────────────────────────────────────────
|
||||
function openNewItem() {
|
||||
itemEditingId.value = null;
|
||||
itemForm.value = emptyItemForm();
|
||||
itemForm.value.phase_id = activePhase.value;
|
||||
itemDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditItem(item) {
|
||||
itemEditingId.value = item.id;
|
||||
itemForm.value = {
|
||||
phase_id: item.phase_id,
|
||||
numero: item.numero,
|
||||
bloco: item.bloco || '',
|
||||
feature: item.feature,
|
||||
descricao: item.descricao || '',
|
||||
esforco: item.esforco || 'M',
|
||||
prioridade: item.prioridade || 'alta',
|
||||
status: item.status,
|
||||
notas: item.notas || '',
|
||||
assignee: item.assignee || '',
|
||||
data_inicio: item.data_inicio || null,
|
||||
data_conclusao: item.data_conclusao || null
|
||||
};
|
||||
itemDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (!itemForm.value.feature.trim() || !itemForm.value.phase_id) return;
|
||||
itemSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
phase_id: itemForm.value.phase_id,
|
||||
numero: itemForm.value.numero ? Number(itemForm.value.numero) : null,
|
||||
bloco: itemForm.value.bloco.trim() || null,
|
||||
feature: itemForm.value.feature.trim(),
|
||||
descricao: itemForm.value.descricao.trim() || null,
|
||||
esforco: itemForm.value.esforco || null,
|
||||
prioridade: itemForm.value.prioridade || null,
|
||||
status: itemForm.value.status,
|
||||
notas: itemForm.value.notas.trim() || null,
|
||||
assignee: itemForm.value.assignee.trim() || null,
|
||||
data_inicio: itemForm.value.data_inicio || null,
|
||||
data_conclusao: itemForm.value.data_conclusao || null
|
||||
};
|
||||
if (itemEditingId.value) {
|
||||
const { error } = await supabase.from('dev_roadmap_items').update(payload).eq('id', itemEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
const maxOrdem = Math.max(
|
||||
0,
|
||||
...items.value.filter((i) => i.phase_id === payload.phase_id).map((i) => i.ordem || 0)
|
||||
);
|
||||
payload.ordem = maxOrdem + 1;
|
||||
const { error } = await supabase.from('dev_roadmap_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
itemDrawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
itemSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDeleteItem() {
|
||||
if (!itemEditingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Excluir este item do roadmap?',
|
||||
header: 'Confirmar',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_roadmap_items').delete().eq('id', itemEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
itemDrawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── CRUD phase ───────────────────────────────────────────────────
|
||||
function openNewPhase() {
|
||||
phaseEditingId.value = null;
|
||||
const maxNum = Math.max(0, ...phases.value.map((p) => p.numero || 0));
|
||||
phaseForm.value = emptyPhaseForm();
|
||||
phaseForm.value.numero = maxNum + 1;
|
||||
phaseDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditPhase() {
|
||||
if (!activePhaseObj.value) return;
|
||||
const p = activePhaseObj.value;
|
||||
phaseEditingId.value = p.id;
|
||||
phaseForm.value = {
|
||||
numero: p.numero,
|
||||
nome: p.nome,
|
||||
objetivo: p.objetivo || '',
|
||||
timeline_sugerida: p.timeline_sugerida || '',
|
||||
criterio_saida: p.criterio_saida || '',
|
||||
status: p.status,
|
||||
data_inicio: p.data_inicio || null,
|
||||
data_fim: p.data_fim || null
|
||||
};
|
||||
phaseDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function savePhase() {
|
||||
if (!phaseForm.value.nome.trim() || !phaseForm.value.numero) return;
|
||||
phaseSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
numero: Number(phaseForm.value.numero),
|
||||
nome: phaseForm.value.nome.trim(),
|
||||
objetivo: phaseForm.value.objetivo.trim() || null,
|
||||
timeline_sugerida: phaseForm.value.timeline_sugerida.trim() || null,
|
||||
criterio_saida: phaseForm.value.criterio_saida.trim() || null,
|
||||
status: phaseForm.value.status,
|
||||
data_inicio: phaseForm.value.data_inicio || null,
|
||||
data_fim: phaseForm.value.data_fim || null
|
||||
};
|
||||
if (phaseEditingId.value) {
|
||||
const { error } = await supabase.from('dev_roadmap_phases').update(payload).eq('id', phaseEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (phases.value.length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_roadmap_phases').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
phaseDrawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
phaseSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDeletePhase() {
|
||||
if (!phaseEditingId.value) return;
|
||||
const phaseItemCount = items.value.filter((i) => i.phase_id === phaseEditingId.value).length;
|
||||
confirm.require({
|
||||
message: `Excluir esta fase? ${phaseItemCount > 0 ? `Ela tem ${phaseItemCount} item(s), que serão excluídos junto.` : ''}`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_roadmap_phases').delete().eq('id', phaseEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Fase excluída', life: 2000 });
|
||||
phaseDrawerOpen.value = false;
|
||||
activePhase.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorderBloco(blocoItems, fromIdx, toIdx) {
|
||||
const hasFilter = filterStatus.value !== 'all' || filterPrior.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Reordena items dentro do bloco + atualiza ordem global
|
||||
const reordered = reorderWithIndexes(blocoItems, fromIdx, toIdx);
|
||||
try {
|
||||
// Pega a ordem base do primeiro item do bloco e incrementa
|
||||
const base = Math.min(...blocoItems.map((i) => i.ordem || 0));
|
||||
const updates = reordered.map((item, idx) =>
|
||||
supabase.from('dev_roadmap_items').update({ ordem: base + idx }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="roadmap">
|
||||
<!-- Seletor de fase -->
|
||||
<div class="phase-selector">
|
||||
<button
|
||||
v-for="phase in phases"
|
||||
:key="phase.id"
|
||||
:class="['phase-btn', { active: phase.id === activePhase }]"
|
||||
@click="activePhase = phase.id"
|
||||
>
|
||||
<span class="phase-btn-num">{{ phase.numero }}</span>
|
||||
<div class="phase-btn-info">
|
||||
<strong>{{ phase.nome }}</strong>
|
||||
<small>{{ phase.timeline_sugerida }}</small>
|
||||
</div>
|
||||
<div class="phase-btn-counts">
|
||||
<span class="done">{{ countByStatus(phase.id, 'concluido') }}</span>
|
||||
<span class="total">/ {{ items.filter((i) => i.phase_id === phase.id).length }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="phase-btn phase-add" @click="openNewPhase" title="Nova fase">
|
||||
<i class="pi pi-plus" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Header da fase ativa -->
|
||||
<div v-if="activePhaseObj" class="phase-header">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="phase-title">
|
||||
<span class="phase-chip">Fase {{ activePhaseObj.numero }}</span>
|
||||
{{ activePhaseObj.nome }}
|
||||
</h2>
|
||||
<p class="phase-desc">{{ activePhaseObj.objetivo }}</p>
|
||||
<div class="phase-meta">
|
||||
<span v-if="activePhaseObj.timeline_sugerida"><i class="pi pi-clock text-xs" /> {{ activePhaseObj.timeline_sugerida }}</span>
|
||||
<span v-if="activePhaseObj.criterio_saida">
|
||||
<i class="pi pi-check-circle text-xs" /> {{ activePhaseObj.criterio_saida }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon" @click="openEditPhase" title="Editar fase">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar feature..." class="filter-search" />
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="concluido">Concluído</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
<option value="bloqueado">Bloqueado</option>
|
||||
</select>
|
||||
<select v-model="filterPrior" class="filter-sel">
|
||||
<option value="all">Todas as prioridades</option>
|
||||
<option value="bloqueador">Bloqueador</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="media">Média</option>
|
||||
<option value="diferencial">Diferencial</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNewItem">
|
||||
<i class="pi pi-plus" /> Novo item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Itens agrupados por bloco -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item encontrado. Use "Novo item" pra adicionar.
|
||||
</div>
|
||||
|
||||
<div v-for="(blocoItems, bloco) in itemsByBloco" :key="bloco" class="bloco">
|
||||
<h3 class="bloco-title">
|
||||
<i class="pi pi-folder-open" />
|
||||
{{ bloco }}
|
||||
<span class="bloco-count">{{ blocoItems.length }}</span>
|
||||
</h3>
|
||||
<BlocoItems :items="blocoItems" :reorder-fn="handleReorderBloco" :on-edit="openEditItem" />
|
||||
</div>
|
||||
|
||||
<!-- Drawer: Item -->
|
||||
<DevDrawer
|
||||
:open="itemDrawerOpen"
|
||||
:title="itemEditingId ? 'Editar item' : 'Novo item de roadmap'"
|
||||
:subtitle="itemEditingId ? `#${itemEditingId}` : ''"
|
||||
:can-save="!!itemForm.feature.trim() && !!itemForm.phase_id"
|
||||
:saving="itemSaving"
|
||||
:danger="!!itemEditingId"
|
||||
@close="itemDrawerOpen = false"
|
||||
@save="saveItem"
|
||||
@delete="askDeleteItem"
|
||||
>
|
||||
<DevField label="Fase" required>
|
||||
<select v-model="itemForm.phase_id">
|
||||
<option v-for="p in phases" :key="p.id" :value="p.id">
|
||||
Fase {{ p.numero }} — {{ p.nome }}
|
||||
</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Bloco" hint="Ex.: Monetização, Compliance BR, IA">
|
||||
<input v-model="itemForm.bloco" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Nº" hint="Opcional (pra referência)">
|
||||
<input v-model="itemForm.numero" type="number" min="1" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Feature" required>
|
||||
<input v-model="itemForm.feature" placeholder="Ex.: Integração com gateway de pagamento" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Descrição">
|
||||
<textarea v-model="itemForm.descricao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row three">
|
||||
<DevField label="Esforço">
|
||||
<select v-model="itemForm.esforco">
|
||||
<option value="">—</option>
|
||||
<option value="S">S (<1 sem)</option>
|
||||
<option value="M">M (1-3 sem)</option>
|
||||
<option value="L">L (3-8 sem)</option>
|
||||
<option value="XL">XL (2+ meses)</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Prioridade">
|
||||
<select v-model="itemForm.prioridade">
|
||||
<option value="">—</option>
|
||||
<option value="bloqueador">Bloqueador</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="media">Média</option>
|
||||
<option value="diferencial">Diferencial</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="itemForm.status">
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="concluido">Concluído</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
<option value="bloqueado">Bloqueado</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Notas/Observações" hint="Contexto extra, decisões, caveats">
|
||||
<textarea v-model="itemForm.notas" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Início">
|
||||
<input v-model="itemForm.data_inicio" type="date" />
|
||||
</DevField>
|
||||
<DevField label="Conclusão">
|
||||
<input v-model="itemForm.data_conclusao" type="date" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Assignee">
|
||||
<input v-model="itemForm.assignee" placeholder="Nome/email" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
|
||||
<!-- Drawer: Phase -->
|
||||
<DevDrawer
|
||||
:open="phaseDrawerOpen"
|
||||
:title="phaseEditingId ? 'Editar fase' : 'Nova fase'"
|
||||
:can-save="!!phaseForm.nome.trim() && !!phaseForm.numero"
|
||||
:saving="phaseSaving"
|
||||
:danger="!!phaseEditingId"
|
||||
@close="phaseDrawerOpen = false"
|
||||
@save="savePhase"
|
||||
@delete="askDeletePhase"
|
||||
>
|
||||
<div class="form-row">
|
||||
<DevField label="Número" required>
|
||||
<input v-model="phaseForm.numero" type="number" min="1" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="phaseForm.status">
|
||||
<option value="planejada">Planejada</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="concluida">Concluída</option>
|
||||
<option value="arquivada">Arquivada</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Nome da fase" required>
|
||||
<input v-model="phaseForm.nome" placeholder="Ex.: MVP Launch" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Objetivo">
|
||||
<textarea v-model="phaseForm.objetivo" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Timeline sugerida">
|
||||
<input v-model="phaseForm.timeline_sugerida" placeholder="Ex.: 4-6 semanas" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Critério de saída">
|
||||
<textarea v-model="phaseForm.criterio_saida" rows="2" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Data de início">
|
||||
<input v-model="phaseForm.data_inicio" type="date" />
|
||||
</DevField>
|
||||
<DevField label="Data de fim">
|
||||
<input v-model="phaseForm.data_fim" type="date" />
|
||||
</DevField>
|
||||
</div>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.roadmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.phase-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.phase-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1.5px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.phase-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
.phase-btn.active {
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
||||
}
|
||||
|
||||
.phase-btn-num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.phase-btn-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.phase-btn-info strong { font-size: 13px; font-weight: 600; }
|
||||
.phase-btn-info small { font-size: 11px; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.phase-btn-counts {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.phase-btn-counts .done { color: #10b981; }
|
||||
.phase-btn-counts .total { color: var(--text-color-secondary); font-size: 11px; }
|
||||
|
||||
.phase-add {
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 16px;
|
||||
min-height: 64px;
|
||||
}
|
||||
.phase-add:hover { color: var(--primary-color); }
|
||||
|
||||
.phase-header {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.phase-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.phase-chip {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.phase-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.phase-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.phase-meta span { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 200px; }
|
||||
.filter-sel { min-width: 160px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bloco {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.bloco-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.bloco-title i { color: var(--primary-color); }
|
||||
.bloco-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
background: var(--surface-border);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row.three {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,731 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevTestsTab.vue — Catálogo de suítes de teste (CRUD + drag-drop)
|
||||
|--------------------------------------------------------------------------
|
||||
| Responde "o que está testado?" sem rodar npm test. Cada linha é uma
|
||||
| suíte (arquivo .spec.js, grupo de testes SQL ou plano manual).
|
||||
-->
|
||||
<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 ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import { useDraggableList, reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const items = ref([]);
|
||||
const filterArea = ref('all');
|
||||
const filterStatus = ref('all');
|
||||
const filterCategoria = ref('all');
|
||||
const search = ref('');
|
||||
const openRow = ref(null);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
area: '',
|
||||
categoria: 'unit',
|
||||
titulo: '',
|
||||
arquivo: '',
|
||||
descricao: '',
|
||||
total_tests: 0,
|
||||
passing: 0,
|
||||
failing: 0,
|
||||
skipped: 0,
|
||||
cobertura_pct: null,
|
||||
status: 'ok',
|
||||
last_run_at: null,
|
||||
sessao_criacao: '',
|
||||
notas: '',
|
||||
tags: ''
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
ok: { label: 'OK', color: '#10b981', bg: 'rgba(16,185,129,.12)', icon: 'pi-check-circle' },
|
||||
falhando: { label: 'Falhando', color: '#ef4444', bg: 'rgba(239,68,68,.12)', icon: 'pi-exclamation-triangle' },
|
||||
pendente: { label: 'Pendente', color: '#0ea5e9', bg: 'rgba(14,165,233,.12)', icon: 'pi-hourglass' },
|
||||
obsoleto: { label: 'Obsoleto', color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-ban' },
|
||||
a_escrever: { label: 'A escrever', color: '#f59e0b', bg: 'rgba(245,158,11,.12)', icon: 'pi-pencil' }
|
||||
};
|
||||
|
||||
const CATEGORIA_OPTIONS = ['unit', 'integration', 'e2e', 'manual', 'a_escrever'];
|
||||
|
||||
const AREA_SUGGESTIONS = [
|
||||
'agenda', 'auth', 'router', 'session', 'stores', 'utils',
|
||||
'pacientes', 'financeiro', 'documentos', 'comunicacao',
|
||||
'saas-admin', 'database', 'rls', 'e2e'
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('dev_test_items')
|
||||
.select('*')
|
||||
.order('area')
|
||||
.order('ordem')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
items.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const areasList = computed(() => {
|
||||
const set = new Set();
|
||||
items.value.forEach((i) => i.area && set.add(i.area));
|
||||
AREA_SUGGESTIONS.forEach((a) => set.add(a));
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value;
|
||||
if (filterArea.value !== 'all') list = list.filter((i) => i.area === filterArea.value);
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterCategoria.value !== 'all') list = list.filter((i) => i.categoria === filterCategoria.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.titulo.toLowerCase().includes(q) ||
|
||||
(i.descricao || '').toLowerCase().includes(q) ||
|
||||
(i.arquivo || '').toLowerCase().includes(q) ||
|
||||
(i.area || '').toLowerCase().includes(q) ||
|
||||
`t#${i.id}`.includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const counts = computed(() => {
|
||||
const total = items.value.reduce((acc, i) => acc + (i.total_tests || 0), 0);
|
||||
const passing = items.value.reduce((acc, i) => acc + (i.passing || 0), 0);
|
||||
const failing = items.value.reduce((acc, i) => acc + (i.failing || 0), 0);
|
||||
return {
|
||||
suites: items.value.length,
|
||||
ok: items.value.filter((i) => i.status === 'ok').length,
|
||||
falhando: items.value.filter((i) => i.status === 'falhando').length,
|
||||
a_escrever: items.value.filter((i) => i.status === 'a_escrever').length,
|
||||
total,
|
||||
passing,
|
||||
failing
|
||||
};
|
||||
});
|
||||
|
||||
function toggle(id) {
|
||||
openRow.value = openRow.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return `${d.toLocaleDateString('pt-BR')} ${d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
if (filterArea.value !== 'all') form.value.area = filterArea.value;
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editingId.value = item.id;
|
||||
form.value = {
|
||||
area: item.area || '',
|
||||
categoria: item.categoria || 'unit',
|
||||
titulo: item.titulo || '',
|
||||
arquivo: item.arquivo || '',
|
||||
descricao: item.descricao || '',
|
||||
total_tests: item.total_tests ?? 0,
|
||||
passing: item.passing ?? 0,
|
||||
failing: item.failing ?? 0,
|
||||
skipped: item.skipped ?? 0,
|
||||
cobertura_pct: item.cobertura_pct,
|
||||
status: item.status || 'ok',
|
||||
last_run_at: item.last_run_at ? item.last_run_at.substring(0, 16) : null,
|
||||
sessao_criacao: item.sessao_criacao || '',
|
||||
notas: item.notas || '',
|
||||
tags: (item.tags || []).join(', ')
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.titulo.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Título obrigatório', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (!form.value.area.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Área obrigatória', life: 3000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
area: form.value.area.trim(),
|
||||
categoria: form.value.categoria.trim() || null,
|
||||
titulo: form.value.titulo.trim(),
|
||||
arquivo: form.value.arquivo.trim() || null,
|
||||
descricao: form.value.descricao.trim() || null,
|
||||
total_tests: Number(form.value.total_tests) || 0,
|
||||
passing: Number(form.value.passing) || 0,
|
||||
failing: Number(form.value.failing) || 0,
|
||||
skipped: Number(form.value.skipped) || 0,
|
||||
cobertura_pct: form.value.cobertura_pct !== '' && form.value.cobertura_pct !== null ? Number(form.value.cobertura_pct) : null,
|
||||
status: form.value.status,
|
||||
last_run_at: form.value.last_run_at || null,
|
||||
sessao_criacao: form.value.sessao_criacao.trim() || null,
|
||||
notas: form.value.notas.trim() || null,
|
||||
tags: form.value.tags
|
||||
? form.value.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: []
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
const { error } = await supabase.from('dev_test_items').update(payload).eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (items.value.filter((i) => i.area === payload.area).length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_test_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
if (!editingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Tem certeza que quer excluir este item?',
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_test_items').delete().eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorder(fromIdx, toIdx) {
|
||||
const hasFilter = filterArea.value !== 'all' || filterStatus.value !== 'all' || filterCategoria.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
detail: 'Drag-drop só funciona com a lista completa.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
const before = items.value;
|
||||
items.value = reorderWithIndexes(before, fromIdx, toIdx);
|
||||
try {
|
||||
const updates = items.value.map((item) =>
|
||||
supabase.from('dev_test_items').update({ ordem: item.ordem }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
} catch (e) {
|
||||
items.value = before;
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
const drag = useDraggableList({ onReorder: handleReorder });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="testes">
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">{{ counts.suites }}</span>
|
||||
<span class="stat-lbl">suítes</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #10b981">
|
||||
<span class="stat-num">{{ counts.passing }}<small class="stat-sub">/{{ counts.total }}</small></span>
|
||||
<span class="stat-lbl">passing</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #ef4444">
|
||||
<span class="stat-num">{{ counts.failing }}</span>
|
||||
<span class="stat-lbl">falhando</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #f59e0b">
|
||||
<span class="stat-num">{{ counts.a_escrever }}</span>
|
||||
<span class="stat-lbl">a escrever</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar (titulo, T#n, area)..." class="filter-search" />
|
||||
<select v-model="filterArea" class="filter-sel">
|
||||
<option value="all">Todas áreas</option>
|
||||
<option v-for="a in areasList" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
<select v-model="filterCategoria" class="filter-sel">
|
||||
<option value="all">Todas categorias</option>
|
||||
<option v-for="c in CATEGORIA_OPTIONS" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="falhando">Falhando</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="a_escrever">A escrever</option>
|
||||
<option value="obsoleto">Obsoleto</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNew">
|
||||
<i class="pi pi-plus" /> Nova suíte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item com os filtros atuais.
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<article
|
||||
v-for="(item, idx) in itemsFiltered"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'test-item',
|
||||
{ open: openRow === item.id, dragging: drag.dragIdx.value === idx, 'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx }
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<header class="test-head">
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
<i
|
||||
:class="['pi', STATUS_LABEL[item.status]?.icon || 'pi-circle']"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color }"
|
||||
/>
|
||||
<div class="test-title-wrap" @click="toggle(item.id)">
|
||||
<h4 class="test-title">
|
||||
<span class="test-ref">T#{{ item.id }}</span>
|
||||
<span class="test-area">{{ item.area }}</span>
|
||||
{{ item.titulo }}
|
||||
</h4>
|
||||
<div class="test-meta">
|
||||
<span v-if="item.categoria" class="test-cat">{{ item.categoria }}</span>
|
||||
<span v-if="item.total_tests > 0" class="test-count">
|
||||
<i class="pi pi-check text-emerald-500" /> {{ item.passing }}/{{ item.total_tests }}
|
||||
<span v-if="item.failing > 0" class="text-red-500"> · {{ item.failing }} falhando</span>
|
||||
</span>
|
||||
<span v-if="item.arquivo" class="test-file">
|
||||
<i class="pi pi-file text-xs" /> {{ item.arquivo }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="test-status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="openEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<i
|
||||
:class="['pi pi-chevron-down test-chev', { open: openRow === item.id }]"
|
||||
@click="toggle(item.id)"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div v-if="openRow === item.id" class="test-body">
|
||||
<div v-if="item.descricao" class="test-section">
|
||||
<h5>Cobertura</h5>
|
||||
<p>{{ item.descricao }}</p>
|
||||
</div>
|
||||
<div v-if="item.notas" class="test-section">
|
||||
<h5>Notas</h5>
|
||||
<p>{{ item.notas }}</p>
|
||||
</div>
|
||||
<div v-if="item.last_run_at || item.sessao_criacao" class="test-section">
|
||||
<h5>Execução</h5>
|
||||
<p>
|
||||
<span v-if="item.last_run_at">Última execução: {{ formatDateTime(item.last_run_at) }}</span>
|
||||
<span v-if="item.sessao_criacao" class="text-[var(--text-color-secondary)]">
|
||||
<span v-if="item.last_run_at"> — </span>Criado na {{ item.sessao_criacao }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="item.cobertura_pct !== null" class="test-section">
|
||||
<h5>Cobertura estimada</h5>
|
||||
<p>{{ item.cobertura_pct }}%</p>
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="test-tags">
|
||||
<span v-for="tag in item.tags" :key="tag" class="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Drawer -->
|
||||
<DevDrawer
|
||||
:open="drawerOpen"
|
||||
:title="editingId ? 'Editar suíte' : 'Nova suíte'"
|
||||
:subtitle="editingId ? `T#${editingId}` : 'Preencha os campos e salve'"
|
||||
:can-save="!!form.titulo.trim() && !!form.area.trim()"
|
||||
:saving="saving"
|
||||
:danger="!!editingId"
|
||||
@close="drawerOpen = false"
|
||||
@save="save"
|
||||
@delete="askDelete"
|
||||
>
|
||||
<div class="form-row">
|
||||
<DevField label="Área" required hint="Ex.: agenda, auth, pacientes, e2e">
|
||||
<input v-model="form.area" list="area-suggestions-tests" placeholder="área" />
|
||||
<datalist id="area-suggestions-tests">
|
||||
<option v-for="a in AREA_SUGGESTIONS" :key="a" :value="a" />
|
||||
</datalist>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Categoria">
|
||||
<select v-model="form.categoria">
|
||||
<option v-for="c in CATEGORIA_OPTIONS" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Título" required>
|
||||
<input v-model="form.titulo" placeholder="Ex.: useRecurrence — geração de ocorrências" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="form.status">
|
||||
<option value="ok">OK — todos passando</option>
|
||||
<option value="falhando">Falhando</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="a_escrever">A escrever</option>
|
||||
<option value="obsoleto">Obsoleto</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Arquivo" hint="src/features/agenda/.../__tests__/*.spec.js">
|
||||
<input v-model="form.arquivo" placeholder="Caminho do arquivo de teste" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Cobertura (descrição)" hint="O que essa suíte cobre">
|
||||
<textarea v-model="form.descricao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row-3">
|
||||
<DevField label="Total">
|
||||
<input v-model.number="form.total_tests" type="number" min="0" />
|
||||
</DevField>
|
||||
<DevField label="Passing">
|
||||
<input v-model.number="form.passing" type="number" min="0" />
|
||||
</DevField>
|
||||
<DevField label="Falhando">
|
||||
<input v-model.number="form.failing" type="number" min="0" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Skipped">
|
||||
<input v-model.number="form.skipped" type="number" min="0" />
|
||||
</DevField>
|
||||
<DevField label="Cobertura %">
|
||||
<input v-model.number="form.cobertura_pct" type="number" min="0" max="100" step="0.01" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Última execução">
|
||||
<input v-model="form.last_run_at" type="datetime-local" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Sessão de criação" hint="Ex.: Sessão 2 — agenda">
|
||||
<input v-model="form.sessao_criacao" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Notas">
|
||||
<textarea v-model="form.notas" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Tags" hint="Separe por vírgula">
|
||||
<input v-model="form.tags" placeholder="tag1, tag2" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.testes { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-left: 3px solid var(--c, var(--primary-color));
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-num { font-size: 22px; font-weight: 700; color: var(--text-color); line-height: 1; }
|
||||
.stat-sub { font-size: 13px; font-weight: 500; color: var(--text-color-secondary); }
|
||||
.stat-lbl { font-size: 11px; color: var(--text-color-secondary); margin-top: 4px; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 220px; }
|
||||
.filter-sel { min-width: 140px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.test-item {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.test-item:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.test-item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.test-item.drop-target { border-color: var(--primary-color); border-style: dashed; background: color-mix(in srgb, var(--primary-color) 5%, transparent); }
|
||||
|
||||
.test-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.drag-handle:hover { opacity: 1; background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.test-head > .pi:nth-child(2) { font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.test-title-wrap { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.test-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.test-ref {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-ground);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
.test-area {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.test-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.test-cat {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.test-count {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.test-file {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.test-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.test-chev {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.test-chev.open { transform: rotate(180deg); }
|
||||
|
||||
.test-body {
|
||||
padding: 0 14px 14px 42px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.test-section { margin-bottom: 10px; }
|
||||
.test-section h5 {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.test-section p {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.test-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row-3 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,732 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevVerificacoesTab.vue — Revisão sênior por área (CRUD + drag-drop)
|
||||
|--------------------------------------------------------------------------
|
||||
| Lista sessão-a-sessão de verificações de qualidade de código por área do
|
||||
| sistema. Diferente da Auditoria (bugs conhecidos), aqui registramos o que
|
||||
| já foi revisado, o que falta revisar, e o resultado de cada revisão.
|
||||
-->
|
||||
<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 ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import { useDraggableList, reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const items = ref([]);
|
||||
const filterArea = ref('all');
|
||||
const filterStatus = ref('all');
|
||||
const filterSev = ref('all');
|
||||
const search = ref('');
|
||||
const openRow = ref(null);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
area: '',
|
||||
categoria: '',
|
||||
titulo: '',
|
||||
descricao: '',
|
||||
resultado: '',
|
||||
acao_sugerida: '',
|
||||
severidade: '',
|
||||
status: 'pendente',
|
||||
verificado_em: null,
|
||||
sessao_verificacao: '',
|
||||
arquivo_afetado: '',
|
||||
auditoria_item_id: null,
|
||||
tags: ''
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pendente: { label: 'Pendente', color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-hourglass' },
|
||||
verificando: { label: 'Verificando', color: '#0ea5e9', bg: 'rgba(14,165,233,.12)', icon: 'pi-search' },
|
||||
ok: { label: 'OK', color: '#10b981', bg: 'rgba(16,185,129,.12)', icon: 'pi-check-circle' },
|
||||
problema: { label: 'Problema', color: '#ef4444', bg: 'rgba(239,68,68,.12)', icon: 'pi-exclamation-triangle' },
|
||||
corrigido: { label: 'Corrigido', color: '#8b5cf6', bg: 'rgba(139,92,246,.12)', icon: 'pi-verified' },
|
||||
wontfix: { label: "Won't fix", color: '#64748b', bg: 'rgba(100,116,139,.12)', icon: 'pi-ban' }
|
||||
};
|
||||
|
||||
const SEV_LABEL = {
|
||||
critico: { label: 'Crítico', color: '#dc2626' },
|
||||
alto: { label: 'Alto', color: '#f59e0b' },
|
||||
medio: { label: 'Médio', color: '#0ea5e9' },
|
||||
baixo: { label: 'Baixo', color: '#94a3b8' }
|
||||
};
|
||||
|
||||
// Áreas sugeridas — usuário pode digitar livre também
|
||||
const AREA_SUGGESTIONS = [
|
||||
'auth', 'router', 'session',
|
||||
'agenda', 'pacientes', 'financeiro', 'documentos',
|
||||
'comunicacao', 'notificacoes', 'whatsapp',
|
||||
'saas-admin', 'billing', 'portal',
|
||||
'stores', 'composables', 'ui-shared',
|
||||
'database', 'rls', 'migrations',
|
||||
'performance', 'seguranca', 'observabilidade'
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('dev_verificacoes_items')
|
||||
.select('*')
|
||||
.order('area')
|
||||
.order('ordem')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
items.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const areasList = computed(() => {
|
||||
const set = new Set();
|
||||
items.value.forEach((i) => i.area && set.add(i.area));
|
||||
AREA_SUGGESTIONS.forEach((a) => set.add(a));
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value;
|
||||
if (filterArea.value !== 'all') list = list.filter((i) => i.area === filterArea.value);
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterSev.value !== 'all') list = list.filter((i) => i.severidade === filterSev.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.titulo.toLowerCase().includes(q) ||
|
||||
(i.descricao || '').toLowerCase().includes(q) ||
|
||||
(i.resultado || '').toLowerCase().includes(q) ||
|
||||
(i.categoria || '').toLowerCase().includes(q) ||
|
||||
(i.area || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const counts = computed(() => ({
|
||||
total: items.value.length,
|
||||
pendente: items.value.filter((i) => i.status === 'pendente').length,
|
||||
verificando: items.value.filter((i) => i.status === 'verificando').length,
|
||||
ok: items.value.filter((i) => i.status === 'ok').length,
|
||||
problema: items.value.filter((i) => i.status === 'problema').length,
|
||||
corrigido: items.value.filter((i) => i.status === 'corrigido').length
|
||||
}));
|
||||
|
||||
function toggle(id) {
|
||||
openRow.value = openRow.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
if (filterArea.value !== 'all') form.value.area = filterArea.value;
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editingId.value = item.id;
|
||||
form.value = {
|
||||
area: item.area || '',
|
||||
categoria: item.categoria || '',
|
||||
titulo: item.titulo || '',
|
||||
descricao: item.descricao || '',
|
||||
resultado: item.resultado || '',
|
||||
acao_sugerida: item.acao_sugerida || '',
|
||||
severidade: item.severidade || '',
|
||||
status: item.status || 'pendente',
|
||||
verificado_em: item.verificado_em || null,
|
||||
sessao_verificacao: item.sessao_verificacao || '',
|
||||
arquivo_afetado: item.arquivo_afetado || '',
|
||||
auditoria_item_id: item.auditoria_item_id || null,
|
||||
tags: (item.tags || []).join(', ')
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.titulo.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Título obrigatório', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (!form.value.area.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Área obrigatória', life: 3000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
area: form.value.area.trim(),
|
||||
categoria: form.value.categoria.trim() || null,
|
||||
titulo: form.value.titulo.trim(),
|
||||
descricao: form.value.descricao.trim() || null,
|
||||
resultado: form.value.resultado.trim() || null,
|
||||
acao_sugerida: form.value.acao_sugerida.trim() || null,
|
||||
severidade: form.value.severidade || null,
|
||||
status: form.value.status,
|
||||
verificado_em: form.value.verificado_em || null,
|
||||
sessao_verificacao: form.value.sessao_verificacao.trim() || null,
|
||||
arquivo_afetado: form.value.arquivo_afetado.trim() || null,
|
||||
auditoria_item_id: form.value.auditoria_item_id || null,
|
||||
tags: form.value.tags
|
||||
? form.value.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: []
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
const { error } = await supabase.from('dev_verificacoes_items').update(payload).eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (items.value.filter((i) => i.area === payload.area).length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_verificacoes_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
if (!editingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Tem certeza que quer excluir este item?',
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_verificacoes_items').delete().eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorder(fromIdx, toIdx) {
|
||||
const hasFilter = filterArea.value !== 'all' || filterStatus.value !== 'all' || filterSev.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
detail: 'Drag-drop só funciona com a lista completa.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
const before = items.value;
|
||||
items.value = reorderWithIndexes(before, fromIdx, toIdx);
|
||||
try {
|
||||
const updates = items.value.map((item) =>
|
||||
supabase.from('dev_verificacoes_items').update({ ordem: item.ordem }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
} catch (e) {
|
||||
items.value = before;
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
const drag = useDraggableList({ onReorder: handleReorder });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="verificacoes">
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">{{ counts.total }}</span>
|
||||
<span class="stat-lbl">total</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #94a3b8">
|
||||
<span class="stat-num">{{ counts.pendente }}</span>
|
||||
<span class="stat-lbl">pendentes</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #0ea5e9">
|
||||
<span class="stat-num">{{ counts.verificando }}</span>
|
||||
<span class="stat-lbl">verificando</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #10b981">
|
||||
<span class="stat-num">{{ counts.ok }}</span>
|
||||
<span class="stat-lbl">OK</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #ef4444">
|
||||
<span class="stat-num">{{ counts.problema }}</span>
|
||||
<span class="stat-lbl">com problema</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #8b5cf6">
|
||||
<span class="stat-num">{{ counts.corrigido }}</span>
|
||||
<span class="stat-lbl">corrigidos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar..." class="filter-search" />
|
||||
<select v-model="filterArea" class="filter-sel">
|
||||
<option value="all">Todas áreas</option>
|
||||
<option v-for="a in areasList" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="verificando">Verificando</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="problema">Problema</option>
|
||||
<option value="corrigido">Corrigido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
</select>
|
||||
<select v-model="filterSev" class="filter-sel">
|
||||
<option value="all">Todas severidades</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNew">
|
||||
<i class="pi pi-plus" /> Novo item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item com os filtros atuais.
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<article
|
||||
v-for="(item, idx) in itemsFiltered"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'verif-item',
|
||||
{ open: openRow === item.id, dragging: drag.dragIdx.value === idx, 'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx }
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<header class="verif-head">
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
<i
|
||||
:class="['pi', STATUS_LABEL[item.status]?.icon || 'pi-circle']"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color }"
|
||||
/>
|
||||
<div class="verif-title-wrap" @click="toggle(item.id)">
|
||||
<h4 class="verif-title">
|
||||
<span class="verif-ref">V#{{ item.id }}</span>
|
||||
<span class="verif-area">{{ item.area }}</span>
|
||||
{{ item.titulo }}
|
||||
</h4>
|
||||
<div class="verif-meta">
|
||||
<span v-if="item.categoria" class="verif-cat">{{ item.categoria }}</span>
|
||||
<span v-if="item.severidade" :style="{ color: SEV_LABEL[item.severidade]?.color }">
|
||||
{{ SEV_LABEL[item.severidade]?.label }}
|
||||
</span>
|
||||
<span v-if="item.arquivo_afetado" class="verif-file">
|
||||
<i class="pi pi-file text-xs" /> {{ item.arquivo_afetado }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="verif-status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="openEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<i
|
||||
:class="['pi pi-chevron-down verif-chev', { open: openRow === item.id }]"
|
||||
@click="toggle(item.id)"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div v-if="openRow === item.id" class="verif-body">
|
||||
<div v-if="item.descricao" class="verif-section">
|
||||
<h5>O que verificar</h5>
|
||||
<p>{{ item.descricao }}</p>
|
||||
</div>
|
||||
<div v-if="item.resultado" class="verif-section">
|
||||
<h5>Resultado</h5>
|
||||
<p>{{ item.resultado }}</p>
|
||||
</div>
|
||||
<div v-if="item.acao_sugerida" class="verif-section">
|
||||
<h5>Ação sugerida</h5>
|
||||
<p>{{ item.acao_sugerida }}</p>
|
||||
</div>
|
||||
<div v-if="item.sessao_verificacao || item.verificado_em" class="verif-section">
|
||||
<h5>Verificação</h5>
|
||||
<p>
|
||||
<span v-if="item.sessao_verificacao">{{ item.sessao_verificacao }}</span>
|
||||
<span v-if="item.verificado_em" class="text-[var(--text-color-secondary)]">
|
||||
— {{ formatDate(item.verificado_em) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="item.auditoria_item_id" class="verif-section">
|
||||
<h5>Vinculado à Auditoria</h5>
|
||||
<p>#{{ item.auditoria_item_id }}</p>
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="verif-tags">
|
||||
<span v-for="tag in item.tags" :key="tag" class="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de edição -->
|
||||
<DevDrawer
|
||||
:open="drawerOpen"
|
||||
:title="editingId ? 'Editar verificação' : 'Nova verificação'"
|
||||
:subtitle="editingId ? `#${editingId}` : 'Preencha os campos e salve'"
|
||||
:can-save="!!form.titulo.trim() && !!form.area.trim()"
|
||||
:saving="saving"
|
||||
:danger="!!editingId"
|
||||
@close="drawerOpen = false"
|
||||
@save="save"
|
||||
@delete="askDelete"
|
||||
>
|
||||
<div class="form-row">
|
||||
<DevField label="Área" required hint="Ex.: auth, agenda, financeiro">
|
||||
<input v-model="form.area" list="area-suggestions" placeholder="área" />
|
||||
<datalist id="area-suggestions">
|
||||
<option v-for="a in AREA_SUGGESTIONS" :key="a" :value="a" />
|
||||
</datalist>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="form.status">
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="verificando">Verificando</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="problema">Problema</option>
|
||||
<option value="corrigido">Corrigido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Título" required>
|
||||
<input v-model="form.titulo" placeholder="Ex.: Session refresh lock não libera em exceção" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Severidade">
|
||||
<select v-model="form.severidade">
|
||||
<option value="">—</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Categoria" hint="Ex.: Performance, Segurança, DX">
|
||||
<input v-model="form.categoria" placeholder="Categoria" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="O que verificar" hint="Descrição do que precisa ser analisado nessa área">
|
||||
<textarea v-model="form.descricao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Resultado" hint="O que foi encontrado ao verificar">
|
||||
<textarea v-model="form.resultado" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Ação sugerida" hint="Como tratar o que foi encontrado">
|
||||
<textarea v-model="form.acao_sugerida" rows="2" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Data da verificação">
|
||||
<input v-model="form.verificado_em" type="date" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Sessão" hint="Ex.: Sessão 1 — auth/router">
|
||||
<input v-model="form.sessao_verificacao" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Arquivo afetado" hint="src/features/agenda/...">
|
||||
<input v-model="form.arquivo_afetado" placeholder="Caminho do arquivo" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Vínculo com Auditoria" hint="ID do item em dev_auditoria_items (se virou bug)">
|
||||
<input v-model.number="form.auditoria_item_id" type="number" placeholder="ID" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Tags" hint="Separe por vírgula">
|
||||
<input v-model="form.tags" placeholder="tag1, tag2" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.verificacoes { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-left: 3px solid var(--c, var(--primary-color));
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-num { font-size: 22px; font-weight: 700; color: var(--text-color); line-height: 1; }
|
||||
.stat-lbl { font-size: 11px; color: var(--text-color-secondary); margin-top: 4px; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 200px; }
|
||||
.filter-sel { min-width: 140px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.verif-item {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.verif-item:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.verif-item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.verif-item.drop-target { border-color: var(--primary-color); border-style: dashed; background: color-mix(in srgb, var(--primary-color) 5%, transparent); }
|
||||
|
||||
.verif-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.drag-handle:hover { opacity: 1; background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.verif-head > .pi:nth-child(2) { font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.verif-title-wrap { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.verif-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.verif-area {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.verif-ref {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-ground);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
.verif-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.verif-cat {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.verif-file {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.verif-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.verif-chev {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.verif-chev.open { transform: rotate(180deg); }
|
||||
|
||||
.verif-body {
|
||||
padding: 0 14px 14px 42px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.verif-section { margin-bottom: 10px; }
|
||||
.verif-section h5 {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.verif-section p {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.verif-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/saas/development/SaasDevelopmentPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import DevOverviewTab from './DevOverviewTab.vue';
|
||||
import DevRoadmapTab from './DevRoadmapTab.vue';
|
||||
import DevEstruturaTab from './DevEstruturaTab.vue';
|
||||
import DevAuditoriaTab from './DevAuditoriaTab.vue';
|
||||
import DevVerificacoesTab from './DevVerificacoesTab.vue';
|
||||
import DevTestsTab from './DevTestsTab.vue';
|
||||
import DevCompetitorsTab from './DevCompetitorsTab.vue';
|
||||
import DevDatabaseTab from './DevDatabaseTab.vue';
|
||||
import DevExportTab from './DevExportTab.vue';
|
||||
|
||||
const activeTab = ref(0);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: 'Visão Geral', icon: 'pi-home', component: DevOverviewTab },
|
||||
{ key: 'roadmap', label: 'Roadmap', icon: 'pi-flag', component: DevRoadmapTab },
|
||||
{ key: 'estrutura', label: 'Estrutura', icon: 'pi-sitemap', component: DevEstruturaTab },
|
||||
{ key: 'auditoria', label: 'Auditoria', icon: 'pi-verified', component: DevAuditoriaTab },
|
||||
{ key: 'verificacoes', label: 'Verificações',icon: 'pi-search', component: DevVerificacoesTab },
|
||||
{ key: 'testes', label: 'Testes', icon: 'pi-check-square',component: DevTestsTab },
|
||||
{ key: 'concorrentes', label: 'Concorrentes',icon: 'pi-globe', component: DevCompetitorsTab },
|
||||
{ key: 'banco', label: 'Banco de Dados',icon: 'pi-database', component: DevDatabaseTab },
|
||||
{ key: 'export', label: 'Exportar', icon: 'pi-download', component: DevExportTab }
|
||||
];
|
||||
|
||||
const activeComponent = computed(() => tabs[activeTab.value]?.component);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<!-- Header -->
|
||||
<section class="dev-header">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-code text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-bold text-xl tracking-tight text-[var(--text-color)]">Desenvolvimento</h1>
|
||||
<p class="text-xs text-[var(--text-color-secondary)]">
|
||||
Área interna de trabalho — roadmap, auditoria, concorrentes, banco
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tabs -->
|
||||
<nav class="dev-tabs">
|
||||
<button
|
||||
v-for="(tab, idx) in tabs"
|
||||
:key="tab.key"
|
||||
:class="['dev-tab', { active: idx === activeTab }]"
|
||||
@click="activeTab = idx"
|
||||
>
|
||||
<i :class="['pi', tab.icon]" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Conteúdo da aba -->
|
||||
<section class="dev-content">
|
||||
<component :is="activeComponent" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dev-page {
|
||||
padding: 16px 20px 40px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dev-header {
|
||||
padding: 10px 14px 14px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dev-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dev-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 13px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dev-tab:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.dev-tab.active {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.dev-tab i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dev-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| BlocoItems.vue — lista de items de um bloco de roadmap com drag-drop local
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { useDraggableList } from '../composables/useDraggableList';
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
reorderFn: { type: Function, required: true },
|
||||
onEdit: { type: Function, required: true }
|
||||
});
|
||||
|
||||
const drag = useDraggableList({
|
||||
onReorder: (from, to) => props.reorderFn(props.items, from, to)
|
||||
});
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pendente: { label: 'Pendente', color: 'var(--text-color-secondary)', bg: 'var(--surface-border)' },
|
||||
em_andamento: { label: 'Em andamento', color: '#f59e0b', bg: 'rgba(245,158,11,.15)' },
|
||||
concluido: { label: 'Concluído', color: '#10b981', bg: 'rgba(16,185,129,.15)' },
|
||||
cancelado: { label: 'Cancelado', color: '#94a3b8', bg: 'rgba(148,163,184,.15)' },
|
||||
bloqueado: { label: 'Bloqueado', color: '#ef4444', bg: 'rgba(239,68,68,.15)' }
|
||||
};
|
||||
|
||||
const PRIOR_LABEL = {
|
||||
bloqueador: { label: 'Bloqueador', color: '#ef4444' },
|
||||
alta: { label: 'Alta', color: '#f59e0b' },
|
||||
media: { label: 'Média', color: '#0ea5e9' },
|
||||
diferencial: { label: 'Diferencial', color: '#a855f7' }
|
||||
};
|
||||
|
||||
const ESFORCO_DESC = {
|
||||
S: '<1 semana',
|
||||
M: '1-3 semanas',
|
||||
L: '3-8 semanas',
|
||||
XL: '2+ meses'
|
||||
};
|
||||
const ESFORCO_COLOR = {
|
||||
S: '#10b981',
|
||||
M: '#0ea5e9',
|
||||
L: '#f59e0b',
|
||||
XL: '#ef4444'
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="items">
|
||||
<li
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'item',
|
||||
{
|
||||
dragging: drag.dragIdx.value === idx,
|
||||
'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx
|
||||
}
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
|
||||
<div class="item-num">{{ item.numero || '•' }}</div>
|
||||
|
||||
<div class="item-main">
|
||||
<div class="item-head">
|
||||
<h4 class="item-feature">{{ item.feature }}</h4>
|
||||
<div class="item-badges">
|
||||
<span
|
||||
v-if="item.esforco"
|
||||
class="badge esforco"
|
||||
:style="{ color: ESFORCO_COLOR[item.esforco], borderColor: ESFORCO_COLOR[item.esforco] }"
|
||||
:title="ESFORCO_DESC[item.esforco]"
|
||||
>{{ item.esforco }}</span>
|
||||
<span
|
||||
v-if="item.prioridade"
|
||||
class="badge prior"
|
||||
:style="{ color: PRIOR_LABEL[item.prioridade]?.color }"
|
||||
>{{ PRIOR_LABEL[item.prioridade]?.label }}</span>
|
||||
<span
|
||||
class="badge status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="onEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.descricao" class="item-desc">{{ item.descricao }}</p>
|
||||
<div v-if="item.notas" class="item-notas">
|
||||
<i class="pi pi-comment text-xs" /> {{ item.notas }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.items { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 6px;
|
||||
border-bottom: 1px dashed var(--surface-border);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.item:last-child { border-bottom: none; }
|
||||
.item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.item.drop-target {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.item:hover .drag-handle { opacity: 1; }
|
||||
.drag-handle:hover { background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.item-num {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-main { flex: 1; min-width: 0; }
|
||||
|
||||
.item-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-feature {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge.esforco {
|
||||
background: transparent;
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.item-notas {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevDrawer — drawer lateral reusável pra forms de edição
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Editar' },
|
||||
subtitle: { type: String, default: '' },
|
||||
width: { type: String, default: '480px' },
|
||||
canSave: { type: Boolean, default: true },
|
||||
saving: { type: Boolean, default: false },
|
||||
saveLabel: { type: String, default: 'Salvar' },
|
||||
danger: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'save', 'delete']);
|
||||
|
||||
function close() {
|
||||
if (props.saving) return;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// ESC fecha
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
document.removeEventListener('keydown', handler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="drawer">
|
||||
<div v-if="open" class="dev-drawer-wrapper">
|
||||
<div class="dev-drawer-backdrop" @click="close" />
|
||||
<aside class="dev-drawer" :style="{ width }">
|
||||
<header class="dd-head">
|
||||
<div class="dd-title-wrap">
|
||||
<h3 class="dd-title">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="dd-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button class="dd-close" @click="close" aria-label="Fechar">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="dd-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer class="dd-foot">
|
||||
<button v-if="$slots.leftAction || props.danger" class="dd-btn dd-btn-danger" @click="emit('delete')">
|
||||
<slot name="leftAction"><i class="pi pi-trash" /> Excluir</slot>
|
||||
</button>
|
||||
<div class="dd-foot-spacer" />
|
||||
<button class="dd-btn dd-btn-ghost" @click="close">Cancelar</button>
|
||||
<button
|
||||
class="dd-btn dd-btn-primary"
|
||||
:disabled="!canSave || saving"
|
||||
@click="emit('save')"
|
||||
>
|
||||
<i v-if="saving" class="pi pi-spin pi-spinner" />
|
||||
<i v-else class="pi pi-check" />
|
||||
{{ saveLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dev-drawer-wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dev-drawer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dev-drawer {
|
||||
position: relative;
|
||||
max-width: 100vw;
|
||||
background: var(--surface-card, #fff);
|
||||
border-left: 1px solid var(--surface-border, #e2e8f0);
|
||||
box-shadow: -8px 0 40px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.dd-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.dd-title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dd-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.dd-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 3px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dd-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dd-close:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dd-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.dd-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.dd-foot-spacer { flex: 1; }
|
||||
|
||||
.dd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.dd-btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
.dd-btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
.dd-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dd-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.dd-btn-ghost:hover {
|
||||
background: var(--surface-card);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dd-btn-danger {
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.dd-btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
/* Transição */
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.drawer-enter-active .dev-drawer,
|
||||
.drawer-leave-active .dev-drawer {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.drawer-enter-from .dev-drawer,
|
||||
.drawer-leave-to .dev-drawer {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevField — wrapper de label/input consistente pros forms de dev
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
hint: { type: String, default: '' },
|
||||
required: { type: Boolean, default: false }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dev-field">
|
||||
<label class="dev-field-label">
|
||||
{{ label }}
|
||||
<span v-if="required" class="dev-field-req">*</span>
|
||||
</label>
|
||||
<slot />
|
||||
<small v-if="hint" class="dev-field-hint">{{ hint }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dev-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dev-field-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.dev-field-req { color: #ef4444; }
|
||||
.dev-field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Styling global pros inputs dentro do field (aplicado via :deep em pais) */
|
||||
.dev-field :deep(input),
|
||||
.dev-field :deep(select),
|
||||
.dev-field :deep(textarea) {
|
||||
background: var(--surface-ground, #f8fafc);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 7px;
|
||||
padding: 8px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.dev-field :deep(input:focus),
|
||||
.dev-field :deep(select:focus),
|
||||
.dev-field :deep(textarea:focus) {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.dev-field :deep(textarea) {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dev-field :deep(select) { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* useDraggableList — drag-drop HTML5 nativo pra reordenação de listas.
|
||||
* Sem deps externas.
|
||||
*
|
||||
* Uso:
|
||||
* const { onDragStart, onDragOver, onDrop, onDragEnd, dragIdx, overIdx, isDragging } = useDraggableList({
|
||||
* onReorder: async (fromIdx, toIdx) => { ... }
|
||||
* });
|
||||
*
|
||||
* <li
|
||||
* v-for="(item, i) in list"
|
||||
* draggable="true"
|
||||
* @dragstart="onDragStart($event, i)"
|
||||
* @dragover.prevent="onDragOver($event, i)"
|
||||
* @drop="onDrop($event, i)"
|
||||
* @dragend="onDragEnd"
|
||||
* :class="{ dragging: dragIdx === i, 'drop-target': overIdx === i && dragIdx !== i }"
|
||||
* >...</li>
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useDraggableList({ onReorder } = {}) {
|
||||
const dragIdx = ref(null);
|
||||
const overIdx = ref(null);
|
||||
|
||||
const isDragging = () => dragIdx.value !== null;
|
||||
|
||||
function onDragStart(e, index) {
|
||||
dragIdx.value = index;
|
||||
overIdx.value = null;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox exige payload pra drag iniciar
|
||||
try {
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
} catch {}
|
||||
}
|
||||
// Adiciona classe pro cursor mudar
|
||||
if (e.target?.classList) e.target.classList.add('is-dragging');
|
||||
}
|
||||
|
||||
function onDragOver(e, index) {
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
overIdx.value = index;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
// Opcional: limpar overIdx quando sair da lista inteira
|
||||
}
|
||||
|
||||
async function onDrop(e, toIndex) {
|
||||
e.preventDefault();
|
||||
const fromIndex = dragIdx.value;
|
||||
dragIdx.value = null;
|
||||
overIdx.value = null;
|
||||
|
||||
if (fromIndex === null || fromIndex === toIndex) return;
|
||||
if (typeof onReorder === 'function') {
|
||||
await onReorder(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(e) {
|
||||
dragIdx.value = null;
|
||||
overIdx.value = null;
|
||||
if (e?.target?.classList) e.target.classList.remove('is-dragging');
|
||||
}
|
||||
|
||||
return {
|
||||
dragIdx,
|
||||
overIdx,
|
||||
isDragging,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordena um array local (imutável) movendo item de `from` pra `to`.
|
||||
*/
|
||||
export function reorderArray(arr, from, to) {
|
||||
const copy = [...arr];
|
||||
const [item] = copy.splice(from, 1);
|
||||
copy.splice(to, 0, item);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza o campo `ordem` de cada item após reorder, e retorna o array
|
||||
* com ordem já atualizada (para persistir no banco).
|
||||
*/
|
||||
export function reorderWithIndexes(arr, from, to, orderField = 'ordem') {
|
||||
const reordered = reorderArray(arr, from, to);
|
||||
return reordered.map((item, idx) => ({ ...item, [orderField]: idx + 1 }));
|
||||
}
|
||||
@@ -1026,7 +1026,9 @@ onMounted(async () => {
|
||||
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<div class="flex items-center justify-between mb-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-lg" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col leading-tight">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
@@ -1109,7 +1111,9 @@ onMounted(async () => {
|
||||
<!-- Agendador Online -->
|
||||
<div v-if="!loading" id="card-agendador" class="dash-card rounded-md" :class="{ '': solicitacoesPendentes > 0 }">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-inbox w-10 h-10 rounded-md cfg-subheader__icon" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-inbox text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Agendamentos Recebidos</div>
|
||||
<div class="dash-card__sub">Solicitações vindas do agendador online</div>
|
||||
@@ -1144,7 +1148,9 @@ onMounted(async () => {
|
||||
<!-- Cadastros externos -->
|
||||
<div v-if="!loading" id="card-cadastros" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-user-plus w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9">
|
||||
<i class="pi pi-user-plus text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Cadastros Recebidos (Externos)</div>
|
||||
<div class="dash-card__sub">Pacientes que preencheram seus próprios dados</div>
|
||||
@@ -1177,7 +1183,9 @@ onMounted(async () => {
|
||||
<!-- Recorrências com alerta -->
|
||||
<div v-if="!loading" id="card-recorrencias" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-refresh w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #f59e0b 15%, transparent); color: #f59e0b" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #f59e0b 15%, transparent); color: #f59e0b">
|
||||
<i class="pi pi-refresh text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Recorrências</div>
|
||||
<div class="dash-card__sub">Atenção necessária</div>
|
||||
@@ -1211,7 +1219,9 @@ onMounted(async () => {
|
||||
<!-- Radar da semana -->
|
||||
<div v-if="!loading" id="card-radar" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-chart-pie w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1">
|
||||
<i class="pi pi-chart-pie text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Radar da Semana</div>
|
||||
<div class="dash-card__sub">Presença, faltas e reposições</div>
|
||||
|
||||
Reference in New Issue
Block a user