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:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
+15 -5
View File
@@ -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ê tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.</div>
+205 -174
View File
@@ -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 -->
<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 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 : 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>
+2 -1
View File
@@ -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;
-4
View File
@@ -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 });
+42 -14
View File
@@ -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>
+9 -15
View File
@@ -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
});
}
}
+493
View File
@@ -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 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 . 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> ativa se o IP teve N falhas recentes. Mostra "Quanto é 7+4?".</li>
<li><b>RPC do intake</b> 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 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&lt;4; i++) {
await fetch('http://127.0.0.1:54321/functions/v1/submit-patient-intake', {
method: 'POST',
headers: {'content-type':'application/json', apikey: '&lt;ANON_KEY&gt;'},
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 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 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 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 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>
+14 -19
View File
@@ -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 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://&lt;projeto&gt;.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 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> 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 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 (&lt;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
| 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>