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:
@@ -714,7 +714,9 @@ onMounted(async () => {
|
||||
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<div class="flex items-center justify-between mb-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-lg" />
|
||||
</div>
|
||||
<div class="flex flex-col leading-tight">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
||||
@@ -788,7 +790,9 @@ onMounted(async () => {
|
||||
<!-- Agendamentos Recebidos -->
|
||||
<div v-if="!loading" class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-inbox w-10 h-10 rounded-md cfg-subheader__icon" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-inbox text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Agendamentos Recebidos</div>
|
||||
<div class="dash-card__sub">Solicitações vindas do agendador online</div>
|
||||
@@ -821,7 +825,9 @@ onMounted(async () => {
|
||||
<!-- Cadastros externos -->
|
||||
<div v-if="!loading" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-user-plus w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9">
|
||||
<i class="pi pi-user-plus text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Cadastros Externos</div>
|
||||
<div class="dash-card__sub">Pacientes que preencheram seus próprios dados</div>
|
||||
@@ -854,7 +860,9 @@ onMounted(async () => {
|
||||
<!-- Ocupação dos terapeutas -->
|
||||
<div v-if="!loading" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-users w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #8b5cf6 15%, transparent); color: #8b5cf6" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #8b5cf6 15%, transparent); color: #8b5cf6">
|
||||
<i class="pi pi-users text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Ocupação dos Terapeutas</div>
|
||||
<div class="dash-card__sub">Sessões este mês por profissional</div>
|
||||
@@ -890,7 +898,9 @@ onMounted(async () => {
|
||||
<!-- Radar da semana -->
|
||||
<div v-if="!loading" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-chart-pie w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1">
|
||||
<i class="pi pi-chart-pie text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Radar da Semana</div>
|
||||
<div class="dash-card__sub">Sessões e faltas da clínica</div>
|
||||
|
||||
@@ -72,8 +72,11 @@ function labelOf(key) {
|
||||
}
|
||||
|
||||
function isPlanDeniedError(e) {
|
||||
const msg = String(e?.message || e || '');
|
||||
return msg.toLowerCase().includes('não permitida') && msg.toLowerCase().includes('plano');
|
||||
const msg = String(e?.message || e || '').toLowerCase();
|
||||
if (msg.includes('não permitida') && msg.includes('plano')) return true;
|
||||
if (msg.includes('fora do plano')) return true;
|
||||
if (msg.includes('saas_admin')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function markPlanDenied(key, e) {
|
||||
@@ -426,7 +429,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('patients')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
@@ -451,7 +454,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('shared_reception')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
@@ -476,7 +479,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('rooms')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.</div>
|
||||
@@ -494,7 +497,7 @@ watch(
|
||||
/>
|
||||
<div v-if="planDenied.has('intake_public')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
Este módulo não está incluído no plano atual. Solicite ao suporte para liberar como exceção comercial.
|
||||
</div>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Você já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import Textarea from 'primevue/textarea';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Message from 'primevue/message';
|
||||
import Popover from 'primevue/popover';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
@@ -32,15 +33,32 @@ import Select from 'primevue/select';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { digitsOnly, isValidEmail, toISODate, generateCPF, fmtCPF } from '@/utils/validators';
|
||||
import MathCaptchaChallenge from '@/components/security/MathCaptchaChallenge.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
// A#20 rev2 — defesa em camadas self-hosted
|
||||
// Honeypot: campo invisível que humano nunca preenche
|
||||
const honeypot = ref('');
|
||||
// Math captcha: ativado sob demanda quando edge function devolve 403 captcha-required
|
||||
const captchaRequired = ref(false);
|
||||
const captchaId = ref('');
|
||||
const captchaAnswer = ref(null);
|
||||
|
||||
const salvando = ref(false);
|
||||
const sucesso = ref(false);
|
||||
|
||||
// A#25: dialog de política de privacidade (LGPD)
|
||||
const policyOpen = ref(false);
|
||||
|
||||
const token = computed(() => String(route.query.t || '').trim());
|
||||
const tokenOk = computed(() => token.value.length >= 10);
|
||||
// A#21: validação mais estrita — aceita UUID (com ou sem hífen, 32 hex chars).
|
||||
// Ainda é cosmético (servidor valida de verdade), mas reduz falsos "verificado".
|
||||
const TOKEN_RX = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const tokenOk = computed(() => TOKEN_RX.test(token.value));
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Helpers (✅ ficam ANTES do enviar())
|
||||
@@ -134,7 +152,6 @@ function preencherMock() {
|
||||
form.rg = generateRG();
|
||||
|
||||
form.observacoes = maybe(0.5) ? 'Cadastro realizado via link externo.' : 'Tenho disponibilidade no período da noite.';
|
||||
form.notas_internas = maybe(0.5) ? 'Paciente demonstrou interesse em iniciar nas próximas semanas.' : '';
|
||||
|
||||
form.onde_nos_conheceu = pick(['Instagram', 'Google', 'Indicação', 'Site', 'Threads', 'Outro']);
|
||||
form.encaminhado_por = maybe(0.45) ? `${pick(first)} ${pick(last)}` : '';
|
||||
@@ -167,63 +184,9 @@ function preencherMock() {
|
||||
toast.add({ severity: 'info', summary: 'Exemplo', detail: 'Campos preenchidos com dados simulados.', life: 1800 });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Foto (upload opcional)
|
||||
// ------------------------------------------------------
|
||||
const fotoFile = ref(null);
|
||||
const fotoPreviewUrl = ref('');
|
||||
const fotoErro = ref('');
|
||||
|
||||
function limparFoto() {
|
||||
fotoFile.value = null;
|
||||
fotoErro.value = '';
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
fotoPreviewUrl.value = '';
|
||||
form.foto_url = '';
|
||||
}
|
||||
|
||||
function onFotoPicked(evt) {
|
||||
fotoErro.value = '';
|
||||
const file = evt?.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type?.startsWith('image/')) {
|
||||
fotoErro.value = 'Arquivo inválido. Envie uma imagem.';
|
||||
return;
|
||||
}
|
||||
const maxMb = 8;
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
fotoErro.value = `Imagem muito grande. Máx: ${maxMb}MB.`;
|
||||
return;
|
||||
}
|
||||
|
||||
fotoFile.value = file;
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
fotoPreviewUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
const AVATAR_BUCKET = 'avatars';
|
||||
|
||||
async function uploadFotoSeHouver() {
|
||||
if (!fotoFile.value) return null;
|
||||
|
||||
const ext = (fotoFile.value.name.split('.').pop() || 'jpg').toLowerCase();
|
||||
const filePath = `intakes/${token.value}/${Date.now()}.${ext}`;
|
||||
|
||||
try {
|
||||
const { error: upErr } = await supabase.storage.from(AVATAR_BUCKET).upload(filePath, fotoFile.value, {
|
||||
upsert: true,
|
||||
contentType: fotoFile.value.type
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(filePath);
|
||||
return pub?.publicUrl || null;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Foto', detail: `Upload falhou: ${e?.message || 'erro desconhecido'}`, life: 4500 });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Upload de foto removido da página pública (A#15):
|
||||
// O bucket não aceita mais upload anônimo. O terapeuta pode pedir a foto
|
||||
// depois, por outro canal, se for necessário.
|
||||
|
||||
// ------------------------------------------------------
|
||||
// CEP (ViaCEP)
|
||||
@@ -299,8 +262,7 @@ const navItems = [
|
||||
{ value: '0', label: 'Informações pessoais', icon: 'pi pi-user' },
|
||||
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
|
||||
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-tags' },
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
|
||||
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' }
|
||||
];
|
||||
|
||||
const navPopover = ref(null);
|
||||
@@ -333,7 +295,6 @@ onBeforeUnmount(() => {
|
||||
if (mql.removeEventListener) mql.removeEventListener('change', mqlHandler);
|
||||
else mql.removeListener(mqlHandler);
|
||||
}
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -378,7 +339,6 @@ function resetForm() {
|
||||
onde_nos_conheceu: '',
|
||||
encaminhado_por: '',
|
||||
observacoes: '',
|
||||
notas_internas: '',
|
||||
nacionalidade: '',
|
||||
|
||||
email_alternativo: '',
|
||||
@@ -396,9 +356,7 @@ function resetForm() {
|
||||
telefone_responsavel: '',
|
||||
cpf_responsavel: '',
|
||||
observacao_responsavel: '',
|
||||
cobranca_no_responsavel: false,
|
||||
|
||||
foto_url: ''
|
||||
cobranca_no_responsavel: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,53 +421,53 @@ async function enviar() {
|
||||
|
||||
salvando.value = true;
|
||||
try {
|
||||
const fotoUrl = await uploadFotoSeHouver();
|
||||
if (fotoUrl) form.foto_url = fotoUrl;
|
||||
// A#17: notas_internas NÃO é enviado (campo interno do terapeuta, não
|
||||
// deve vir do paciente). A#15: avatar_url removido (upload não é mais
|
||||
// possível anonimamente). Todos os campos texto são recortados em
|
||||
// length máximo client-side (defesa em camadas — o RPC também valida).
|
||||
const truncate = (v, n) => {
|
||||
const s = cleanStr(v);
|
||||
return s ? String(s).slice(0, n) : null;
|
||||
};
|
||||
|
||||
const payload = {
|
||||
// essenciais
|
||||
nome_completo: cleanStr(form.nome_completo),
|
||||
email_principal: cleanStr(form.email_principal)?.toLowerCase() || null,
|
||||
nome_completo: truncate(form.nome_completo, 200),
|
||||
email_principal: cleanStr(form.email_principal)?.toLowerCase().slice(0, 120) || null,
|
||||
telefone: digitsOnly(form.telefone),
|
||||
|
||||
avatar_url: form.foto_url || null,
|
||||
|
||||
// alternativos
|
||||
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase() || null,
|
||||
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase().slice(0, 120) || null,
|
||||
telefone_alternativo: digitsOnly(form.telefone_alternativo) || null,
|
||||
|
||||
// docs / endereço
|
||||
cpf: digitsOnly(form.cpf),
|
||||
rg: cleanStr(form.rg),
|
||||
rg: truncate(form.rg, 20),
|
||||
cep: digitsOnly(form.cep),
|
||||
|
||||
pais: cleanStr(form.pais) || 'Brasil',
|
||||
cidade: cleanStr(form.cidade),
|
||||
estado: cleanStr(form.estado) || 'SP',
|
||||
endereco: cleanStr(form.endereco),
|
||||
numero: cleanStr(form.numero),
|
||||
bairro: cleanStr(form.bairro),
|
||||
complemento: cleanStr(form.complemento),
|
||||
pais: truncate(form.pais, 60) || 'Brasil',
|
||||
cidade: truncate(form.cidade, 120),
|
||||
estado: truncate(form.estado, 2) || 'SP',
|
||||
endereco: truncate(form.endereco, 200),
|
||||
numero: truncate(form.numero, 20),
|
||||
bairro: truncate(form.bairro, 120),
|
||||
complemento: truncate(form.complemento, 120),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: isoBirth || null,
|
||||
naturalidade: cleanStr(form.naturalidade),
|
||||
naturalidade: truncate(form.naturalidade, 120),
|
||||
genero: cleanStr(form.genero),
|
||||
estado_civil: cleanStr(form.estado_civil),
|
||||
|
||||
// adicionais (existem na tabela!)
|
||||
profissao: cleanStr(form.profissao),
|
||||
escolaridade: cleanStr(form.escolaridade),
|
||||
nacionalidade: cleanStr(form.nacionalidade),
|
||||
// adicionais
|
||||
profissao: truncate(form.profissao, 120),
|
||||
escolaridade: truncate(form.escolaridade, 120),
|
||||
nacionalidade: truncate(form.nacionalidade, 80),
|
||||
|
||||
// origem + texto
|
||||
onde_nos_conheceu: cleanStr(form.onde_nos_conheceu),
|
||||
encaminhado_por: cleanStr(form.encaminhado_por),
|
||||
observacoes: cleanStr(form.observacoes),
|
||||
|
||||
// ⚠️ eu recomendo NÃO enviar isso no externo,
|
||||
// mas a coluna existe — então deixo opcional:
|
||||
notas_internas: cleanStr(form.notas_internas),
|
||||
// origem + observações
|
||||
onde_nos_conheceu: truncate(form.onde_nos_conheceu, 80),
|
||||
encaminhado_por: truncate(form.encaminhado_por, 120),
|
||||
observacoes: truncate(form.observacoes, 2000),
|
||||
|
||||
// consent
|
||||
consent: !!consent.value
|
||||
@@ -519,28 +477,68 @@ async function enviar() {
|
||||
if (payload[k] === undefined) delete payload[k];
|
||||
});
|
||||
|
||||
let { error } = await supabase.rpc('create_patient_intake_request_v2', {
|
||||
p_token: token.value,
|
||||
p_payload: payload
|
||||
});
|
||||
// A#24: envia user_agent pra log de tentativas (sem PII).
|
||||
const clientInfo = (typeof navigator !== 'undefined' && navigator.userAgent)
|
||||
? String(navigator.userAgent).slice(0, 500)
|
||||
: null;
|
||||
|
||||
const isFnMissing = error && (error.code === '42883' || /create_patient_intake_request_v2/i.test(error.message));
|
||||
|
||||
const isOldSchemaColumn = error && (/column .* of relation .*patient_intake_requests.* does not exist/i.test(error.message) || /column "name" of relation "patient_intake_requests" does not exist/i.test(error.message));
|
||||
|
||||
if (isFnMissing || isOldSchemaColumn) {
|
||||
const { error: e2 } = await supabase.rpc('create_patient_intake_request', {
|
||||
p_token: token.value,
|
||||
p_name: payload.nome_completo,
|
||||
p_email: payload.email_principal,
|
||||
p_phone: payload.telefone,
|
||||
p_notes: payload.observacoes || null,
|
||||
p_consent: !!payload.consent
|
||||
});
|
||||
error = e2;
|
||||
// A#20 rev2: se já está exigindo captcha mas usuário não respondeu, bloqueia local
|
||||
if (captchaRequired.value && (!captchaId.value || captchaAnswer.value == null)) {
|
||||
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Responda a pergunta de verificação antes de enviar.', life: 3000 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
const { data, error } = await supabase.functions.invoke('submit-patient-intake', {
|
||||
body: {
|
||||
token: token.value,
|
||||
payload,
|
||||
website: honeypot.value, // honeypot field
|
||||
captcha_id: captchaId.value || null,
|
||||
captcha_answer: captchaAnswer.value,
|
||||
client_info: clientInfo
|
||||
}
|
||||
});
|
||||
|
||||
// Quando edge devolve 4xx/5xx, supabase-js coloca a Response em error.context.
|
||||
// O body com {error: 'captcha-required' | 'rate-limited' | ...} continua acessível.
|
||||
let errBody = null;
|
||||
if (error?.context?.json) {
|
||||
try { errBody = await error.context.json(); } catch { /* sem body */ }
|
||||
}
|
||||
const errMsg = String(errBody?.error || data?.error || error?.message || '');
|
||||
if (/captcha-required/i.test(errMsg)) {
|
||||
captchaRequired.value = true;
|
||||
captchaId.value = '';
|
||||
captchaAnswer.value = null;
|
||||
toast.add({ severity: 'warn', summary: 'Verificação extra', detail: 'Por segurança, responda a pergunta abaixo.', life: 3500 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (/captcha-wrong/i.test(errMsg)) {
|
||||
captchaId.value = '';
|
||||
captchaAnswer.value = null;
|
||||
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Resposta incorreta. Tente novamente.', life: 3500 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (/rate-limited/i.test(errMsg)) {
|
||||
const retryAfter = errBody?.retry_after_seconds || data?.retry_after_seconds;
|
||||
const after = retryAfter ? Math.ceil(retryAfter / 60) : null;
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Muitas tentativas',
|
||||
detail: after ? `Tente novamente em ${after} min.` : 'Tente novamente mais tarde.',
|
||||
life: 6000
|
||||
});
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) throw new Error(data?.message || error.message || 'Falha ao enviar');
|
||||
if (data?.error) throw new Error(data?.message || data.error);
|
||||
|
||||
sucesso.value = true;
|
||||
toast.add({ severity: 'success', summary: 'Enviado', detail: 'Cadastro enviado com sucesso.', life: 2500 });
|
||||
@@ -620,7 +618,8 @@ watch(
|
||||
|
||||
<!-- actions -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
|
||||
<!-- A#26: Botão de preencher mock apenas em DEV -->
|
||||
<Button v-if="isDev" label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
|
||||
<Button label="Enviar" icon="pi pi-send" class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950" :loading="salvando" :disabled="salvando || sucesso || !tokenOk" @click="enviar" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,52 +634,8 @@ watch(
|
||||
<!-- Left panel (inside modal) -->
|
||||
<aside class="col-span-12 md:col-span-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<!-- Photo -->
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="relative">
|
||||
<div class="h-20 w-20 overflow-hidden rounded-2xl border border-white/10 bg-white/5">
|
||||
<img v-if="fotoPreviewUrl || form.foto_url" :src="fotoPreviewUrl || form.foto_url" alt="foto" class="h-full w-full object-cover" />
|
||||
<div v-else class="grid h-full w-full place-items-center text-slate-300">
|
||||
<i class="pi pi-user text-2xl opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute -inset-1 rounded-2xl bg-emerald-400/10 blur-lg" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">Foto (opcional)</div>
|
||||
<div class="mt-1 text-xs text-slate-300">Se preferir, pode deixar sem foto.</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
|
||||
<input type="file" accept="image/*" class="hidden" :disabled="salvando" @change="onFotoPicked" />
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i class="pi pi-upload" />
|
||||
Enviar imagem
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
|
||||
<input type="file" accept="image/*" capture="user" class="hidden" :disabled="salvando" @change="onFotoPicked" />
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i class="pi pi-camera" />
|
||||
Usar câmera (celular)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="fotoFile" class="flex gap-2">
|
||||
<Button type="button" icon="pi pi-trash" severity="secondary" outlined size="small" label="Remover" class="!border-white/20 !text-slate-100" :disabled="salvando" @click="limparFoto" />
|
||||
</div>
|
||||
|
||||
<div v-if="fotoErro" class="rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
|
||||
{{ fotoErro }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section chips -->
|
||||
<div class="mt-5">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate-200">Seções</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -1124,19 +1079,6 @@ watch(
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 4 -->
|
||||
<AccordionPanel value="4">
|
||||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 4)"> 5. Anotações </AccordionHeader>
|
||||
<AccordionContent>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-file-edit" />
|
||||
<Textarea id="f_notas_internas" v-model="form.notas_internas" class="w-full" autoResize rows="6" variant="filled" :disabled="salvando" />
|
||||
</IconField>
|
||||
<label for="f_notas_internas">Notas</label>
|
||||
</FloatLabel>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
|
||||
<!-- Footer / Consent + CTA -->
|
||||
@@ -1146,7 +1088,12 @@ watch(
|
||||
<Checkbox v-model="consent" :binary="true" :disabled="salvando" inputId="ext_consent" />
|
||||
<div class="min-w-0">
|
||||
<label for="ext_consent" class="block text-sm font-semibold text-slate-100"> Concordo em enviar meus dados para o terapeuta. </label>
|
||||
<div class="mt-1 text-xs text-slate-300">Seus dados serão usados apenas para contato e organização do atendimento.</div>
|
||||
<div class="mt-1 text-xs text-slate-300">
|
||||
Seus dados serão usados apenas para contato e organização do atendimento.
|
||||
<button type="button" class="ml-1 underline text-emerald-300 hover:text-emerald-200 focus:outline-none" @click="policyOpen = true">
|
||||
Ver política de privacidade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errors.consentimento" class="mt-2 rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
|
||||
{{ errors.consentimento }}
|
||||
@@ -1155,6 +1102,25 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A#20 rev2: HONEYPOT invisível — bot preenche tudo, humano nunca vê -->
|
||||
<div class="absolute -left-[9999px]" aria-hidden="true">
|
||||
<label for="ext_website">Não preencha este campo</label>
|
||||
<input
|
||||
id="ext_website"
|
||||
v-model="honeypot"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- A#20 rev2: math captcha CONDICIONAL — só aparece quando edge pedir -->
|
||||
<MathCaptchaChallenge
|
||||
v-if="captchaRequired"
|
||||
v-model:id="captchaId"
|
||||
v-model:answer="captchaAnswer"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs text-slate-300">Se algo falhar, peça um novo link ao terapeuta.</div>
|
||||
|
||||
@@ -1163,7 +1129,7 @@ watch(
|
||||
icon="pi pi-send"
|
||||
class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950 md:!min-w-[240px]"
|
||||
:loading="salvando"
|
||||
:disabled="salvando || sucesso || !tokenOk"
|
||||
:disabled="salvando || sucesso || !tokenOk || (captchaRequired && (!captchaId || captchaAnswer == null))"
|
||||
@click="enviar"
|
||||
/>
|
||||
</div>
|
||||
@@ -1178,5 +1144,70 @@ watch(
|
||||
<div class="mt-4 text-center text-xs text-slate-400">Ao enviar, você confirma que está fornecendo informações verdadeiras e que autoriza o contato.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A#25: Política de privacidade (dialog inline) -->
|
||||
<Dialog
|
||||
v-model:visible="policyOpen"
|
||||
modal
|
||||
header="Política de privacidade"
|
||||
:style="{ width: '92vw', maxWidth: '720px' }"
|
||||
:breakpoints="{ '640px': '96vw' }"
|
||||
:draggable="false"
|
||||
:dismissableMask="true"
|
||||
>
|
||||
<div class="text-sm leading-relaxed text-[var(--text-color)] space-y-4">
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Finalidade do tratamento</h3>
|
||||
<p>Seus dados pessoais são coletados exclusivamente para viabilizar o contato do terapeuta com você e organizar o atendimento clínico. Não são usados para marketing, compartilhamento com terceiros ou perfilamento.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Dados coletados</h3>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Identificação: nome completo, CPF, RG (opcional), data de nascimento, gênero, estado civil, nacionalidade, naturalidade.</li>
|
||||
<li>Contato: telefone(s), e-mail(s), endereço.</li>
|
||||
<li>Contexto: profissão, escolaridade, parente/responsável, origem do contato, observações que você optar por fornecer.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Base legal (LGPD)</h3>
|
||||
<p>Tratamos seus dados com base no <b>consentimento</b> que você fornece ao marcar a caixa correspondente (Art. 7º, I da Lei 13.709/2018), e quando aplicável, também com base na execução de contrato de prestação de serviço terapêutico (Art. 7º, V).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Retenção</h3>
|
||||
<p>Dados de pré-cadastro são mantidos pelo tempo necessário para a conversão em paciente (ou descarte, se não houver andamento). Após início do vínculo terapêutico, seguem as regras aplicáveis ao prontuário clínico (Resolução CFP 001/2009: no mínimo 5 anos após último atendimento).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Seus direitos</h3>
|
||||
<p>A qualquer momento você pode solicitar, sem custo:</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Confirmação da existência de tratamento;</li>
|
||||
<li>Acesso aos seus dados;</li>
|
||||
<li>Correção de dados incompletos, inexatos ou desatualizados;</li>
|
||||
<li>Anonimização, bloqueio ou eliminação de dados desnecessários;</li>
|
||||
<li>Portabilidade dos dados;</li>
|
||||
<li>Eliminação dos dados pessoais tratados com base no consentimento;</li>
|
||||
<li>Revogação do consentimento.</li>
|
||||
</ul>
|
||||
<p class="mt-2">Para exercer qualquer desses direitos, entre em contato diretamente com o profissional que enviou este link.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Segurança</h3>
|
||||
<p>Adotamos medidas técnicas (criptografia em trânsito, controle de acesso, registros de auditoria) e administrativas para proteger seus dados. Ainda assim, nenhum sistema é 100% seguro — comprometa apenas o estritamente necessário.</p>
|
||||
</section>
|
||||
|
||||
<section class="text-xs text-[var(--text-color-secondary)]">
|
||||
<p>Esta política pode ser atualizada. Data da última revisão: 18/04/2026.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Entendi" icon="pi pi-check" @click="policyOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import Chip from 'primevue/chip';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionTab from 'primevue/accordiontab';
|
||||
import Avatar from 'primevue/avatar';
|
||||
import AvatarGroup from 'primevue/avatargroup';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const brandName = 'Psi Quasar'; // ajuste para o nome final do produto
|
||||
const year = computed(() => new Date().getFullYear());
|
||||
|
||||
function go(path) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
function scrollTo(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
const featuredPlanKey = computed(() => {
|
||||
const list = Array.isArray(pricing.value) ? pricing.value : [];
|
||||
const featured = list.find((p) => p && p.is_featured && p.is_visible);
|
||||
return featured?.plan_key || null;
|
||||
});
|
||||
|
||||
function goStart() {
|
||||
if (featuredPlanKey.value) {
|
||||
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/auth/signup');
|
||||
}
|
||||
|
||||
const features = ref([
|
||||
{
|
||||
title: 'Agenda inteligente',
|
||||
desc: 'Configure sua semana, encaixes, bloqueios e visão por dia/semana.',
|
||||
icon: 'pi pi-calendar'
|
||||
},
|
||||
{
|
||||
title: 'Autoagendamento (PRO)',
|
||||
desc: 'Página para o paciente confirmar, agendar e reagendar sem fricção.',
|
||||
icon: 'pi pi-globe',
|
||||
pro: true
|
||||
},
|
||||
{
|
||||
title: 'Prontuário e sessões',
|
||||
desc: 'Registro por paciente, histórico por sessão e organização por linha do tempo.',
|
||||
icon: 'pi pi-file-edit'
|
||||
},
|
||||
{
|
||||
title: 'Financeiro integrado',
|
||||
desc: 'Receitas, despesas e visão do mês conectadas ao que acontece na agenda.',
|
||||
icon: 'pi pi-wallet'
|
||||
},
|
||||
{
|
||||
title: 'Pacientes e tags',
|
||||
desc: 'Segmentação por grupos, etiquetas e filtros práticos para achar rápido.',
|
||||
icon: 'pi pi-users'
|
||||
},
|
||||
{
|
||||
title: 'Clínica / multi-profissional',
|
||||
desc: 'Múltiplos profissionais, agendas separadas, papéis e visão gerencial.',
|
||||
icon: 'pi pi-building'
|
||||
}
|
||||
]);
|
||||
|
||||
/** PRICING dinâmico do SaaS */
|
||||
const billingInterval = ref('year'); // 'month' | 'year'
|
||||
const pricing = ref([]);
|
||||
const loadingPricing = ref(false);
|
||||
|
||||
function formatBRLFromCents(cents) {
|
||||
if (cents == null) return '—';
|
||||
const v = Number(cents) / 100;
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function priceFor(p) {
|
||||
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents;
|
||||
}
|
||||
|
||||
async function fetchPricing() {
|
||||
loadingPricing.value = true;
|
||||
|
||||
const { data, error } = await supabase.from('v_public_pricing').select('*').eq('is_visible', true).order('sort_order', { ascending: true });
|
||||
|
||||
loadingPricing.value = false;
|
||||
|
||||
if (!error) pricing.value = data || [];
|
||||
}
|
||||
|
||||
onMounted(fetchPricing);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
|
||||
<!-- TOPBAR -->
|
||||
<div class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur">
|
||||
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm">
|
||||
<i class="pi pi-sparkles text-lg opacity-80" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
|
||||
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="relative overflow-hidden">
|
||||
<!-- blobs / noir glow -->
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
|
||||
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
|
||||
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
|
||||
<div class="grid grid-cols-12 gap-6 items-center">
|
||||
<div class="col-span-12 lg:col-span-7">
|
||||
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
|
||||
|
||||
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">Uma agenda inteligente, um prontuário organizado, um financeiro respirável.</h1>
|
||||
|
||||
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">Centralize a rotina clínica em um lugar só: pacientes, sessões, lembretes e indicadores. Menos dispersão. Mais presença.</p>
|
||||
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-2">
|
||||
<Button label="Criar conta grátis" icon="pi pi-arrow-right" class="w-full sm:w-auto" @click="goStart()" />
|
||||
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full sm:w-auto" @click="scrollTo('pricing')" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<Tag severity="secondary" value="Agenda online (PRO)" />
|
||||
<Tag severity="secondary" value="Controle de sessões" />
|
||||
<Tag severity="secondary" value="Financeiro integrado" />
|
||||
<Tag severity="secondary" value="Clínica / multi-profissional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<Card class="overflow-hidden">
|
||||
<template #content>
|
||||
<div class="p-1">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-lg">Painel de hoje</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
|
||||
</div>
|
||||
<i class="pi pi-chart-line opacity-70" />
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
|
||||
<div class="text-2xl font-semibold mt-1">6</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
|
||||
<div class="text-2xl font-semibold mt-1">R$ 840</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
|
||||
<div class="font-semibold mt-1">Anotações e histórico</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">organizado por paciente, sessão e linha do tempo</div>
|
||||
</div>
|
||||
<i class="pi pi-file-edit opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">* Ilustração conceitual do produto.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TRUST / VALUE STRIP -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-calendar opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Agenda e autoagendamento</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O paciente confirma, agenda e reagenda com autonomia (PRO).</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-wallet opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Financeiro integrado</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Receita/despesa junto da agenda — sem planilhas espalhadas.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-lock opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Prontuário e controle de sessões</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Registro clínico e histórico acessíveis, com backups e organização.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de clínica.</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O foco é tirar o excesso de fricção sem invadir o que é do seu método.</div>
|
||||
</div>
|
||||
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i :class="f.icon" class="opacity-80" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">{{ f.title }}</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
{{ f.desc }}
|
||||
</div>
|
||||
<div v-if="f.pro" class="mt-2">
|
||||
<Tag severity="warning" value="PRO" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="my-8" />
|
||||
|
||||
<Accordion :activeIndex="0">
|
||||
<AccordionTab header="Como fica o fluxo na prática?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento. O sistema existe para manter o consultório respirando — não para virar uma burocracia nova.
|
||||
</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="E para clínica (multi-profissionais)?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">Perfis por função, agendas separadas, repasses e visão gerencial — quando você estiver pronto para crescer.</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="Privacidade e segurança">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes de conformidade você pode expor numa página própria de segurança/LGPD.)
|
||||
</div>
|
||||
</AccordionTab>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
<!-- PRICING (dinâmico do SaaS) -->
|
||||
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
|
||||
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
|
||||
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">Comece simples. Suba para PRO quando a agenda pedir automação.</div>
|
||||
|
||||
<!-- header conceitual + toggle -->
|
||||
<div class="flex flex-col items-center text-center mt-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<AvatarGroup>
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
|
||||
</AvatarGroup>
|
||||
|
||||
<Divider layout="vertical" />
|
||||
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
|
||||
<Button label="Mensal" size="small" :severity="billingInterval === 'month' ? 'success' : 'secondary'" :outlined="billingInterval !== 'month'" @click="billingInterval = 'month'" />
|
||||
<Button label="Anual" size="small" :severity="billingInterval === 'year' ? 'success' : 'secondary'" :outlined="billingInterval !== 'year'" class="ml-1" @click="billingInterval = 'year'" />
|
||||
</div>
|
||||
<div v-if="billingInterval === 'year'" class="mt-2">
|
||||
<Tag severity="success" value="Economize até 20%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">Carregando planos...</div>
|
||||
|
||||
<div v-else class="mt-8 grid grid-cols-12 gap-4">
|
||||
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full overflow-hidden transition-transform" :class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''">
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">
|
||||
{{ p.badge || 'Plano' }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ p.public_name || p.plan_name || p.plan_key }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tag v-if="p.is_featured" severity="success" value="Popular" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-3xl font-semibold leading-none">
|
||||
{{ formatBRLFromCents(priceFor(p)) }}
|
||||
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
|
||||
|
||||
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
|
||||
{{ p.public_description }}
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
|
||||
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-emerald-500"></i>
|
||||
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
|
||||
{{ b.text }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
label="Começar"
|
||||
class="w-full"
|
||||
:severity="p.is_featured ? 'success' : 'secondary'"
|
||||
:outlined="!p.is_featured"
|
||||
icon="pi pi-arrow-right"
|
||||
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO) sem mexer no código.</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="border-t border-[var(--surface-border)]">
|
||||
<div class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="font-semibold">{{ brandName }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} — Todos os direitos reservados.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
|
||||
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,142 +0,0 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/public/PatientsExternalLinkPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Message from 'primevue/message';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'; // ajuste se seu caminho for diferente
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const inviteToken = ref('');
|
||||
const rotating = ref(false);
|
||||
|
||||
const origin = computed(() => window.location.origin);
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return '';
|
||||
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`;
|
||||
});
|
||||
|
||||
function newToken() {
|
||||
// browsers modernos
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
// fallback simples
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
async function requireUserId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite() {
|
||||
const uid = await requireUserId();
|
||||
|
||||
const { data, error } = await supabase.from('patient_invites').select('token, active').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const token = data?.[0]?.token;
|
||||
if (token) {
|
||||
inviteToken.value = token;
|
||||
return;
|
||||
}
|
||||
|
||||
const t = newToken();
|
||||
const { error: insErr } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
|
||||
|
||||
if (insErr) throw insErr;
|
||||
inviteToken.value = t;
|
||||
}
|
||||
|
||||
async function rotateLink() {
|
||||
rotating.value = true;
|
||||
try {
|
||||
const t = newToken();
|
||||
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t });
|
||||
if (error) throw error;
|
||||
|
||||
inviteToken.value = t;
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 });
|
||||
} finally {
|
||||
rotating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
if (!publicUrl.value) return;
|
||||
await navigator.clipboard.writeText(publicUrl.value);
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 });
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
if (!publicUrl.value) return;
|
||||
window.open(publicUrl.value, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadOrCreateInvite();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER -->
|
||||
<div class="flex flex-column md:flex-row md:align-items-center md:justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="text-900 text-2xl font-semibold">Cadastro Externo</div>
|
||||
<div class="text-600 mt-1">Gere um link para o paciente preencher o pré-cadastro.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined :loading="rotating" @click="rotateLink" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CARD -->
|
||||
<Card class="mt-4">
|
||||
<template #title>Seu link</template>
|
||||
<template #subtitle>Envie este link ao paciente.</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div class="p-inputgroup">
|
||||
<InputText readonly :value="publicUrl" placeholder="Gerando seu link…" />
|
||||
<Button icon="pi pi-copy" severity="secondary" outlined :disabled="!publicUrl" @click="copyLink" v-tooltip.bottom="'Copiar'" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" outlined :disabled="!publicUrl" @click="openLink" v-tooltip.bottom="'Abrir'" />
|
||||
</div>
|
||||
|
||||
<Message v-if="!inviteToken" severity="info" :closable="false"> Gerando seu link... </Message>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,6 +17,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
@@ -273,7 +274,7 @@ async function loadIntents() {
|
||||
totalIntentsNew.value = Number(cNew || 0);
|
||||
totalIntentsPaid.value = Number(cPaid || 0);
|
||||
} catch (e) {
|
||||
console.warn('[SAAS] loadIntents failed:', e);
|
||||
logError('SaasDashboard', 'loadIntents failed', e);
|
||||
intents.value = [];
|
||||
totalIntents.value = 0;
|
||||
totalIntentsNew.value = 0;
|
||||
|
||||
@@ -329,10 +329,6 @@ async function load() {
|
||||
const { data, error } = await supabase.from('saas_docs').select('*').order('pagina_path').order('ordem');
|
||||
if (error) throw error;
|
||||
docs.value = data || [];
|
||||
console.log(
|
||||
'docs carregados:',
|
||||
docs.value.map((d) => ({ titulo: d.titulo, votos_util: d.votos_util, votos_nao_util: d.votos_nao_util }))
|
||||
);
|
||||
setDocs(docs.value);
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 });
|
||||
|
||||
@@ -33,6 +33,7 @@ const saving = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const q = ref('');
|
||||
const showInactive = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
@@ -86,8 +87,10 @@ const filteredRows = computed(() => {
|
||||
const term = String(q.value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!term) return rows.value;
|
||||
return (rows.value || []).filter((r) => {
|
||||
let list = rows.value || [];
|
||||
if (!showInactive.value) list = list.filter((r) => r.is_active !== false);
|
||||
if (!term) return list;
|
||||
return list.filter((r) => {
|
||||
return [r.key, r.name, r.descricao].some((s) =>
|
||||
String(s || '')
|
||||
.toLowerCase()
|
||||
@@ -99,7 +102,7 @@ const filteredRows = computed(() => {
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('features').select('id, key, name, descricao, created_at').order('key', { ascending: true });
|
||||
const { data, error } = await supabase.from('features').select('id, key, name, descricao, created_at, is_active').order('key', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
rows.value = data || [];
|
||||
@@ -192,26 +195,40 @@ async function save() {
|
||||
|
||||
function askDelete(row) {
|
||||
confirm.require({
|
||||
message: `Excluir o recurso "${row.key}"?`,
|
||||
header: 'Confirmar exclusão',
|
||||
message: `Depreciar o recurso "${row.key}"? Tenants que já têm o recurso continuam com ele; só some do catálogo.`,
|
||||
header: 'Depreciar recurso',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => doDelete(row)
|
||||
accept: () => doSoftDelete(row, false)
|
||||
});
|
||||
}
|
||||
|
||||
async function doDelete(row) {
|
||||
function askReactivate(row) {
|
||||
confirm.require({
|
||||
message: `Reativar o recurso "${row.key}"? Volta ao catálogo e fica disponível para novos planos.`,
|
||||
header: 'Reativar recurso',
|
||||
icon: 'pi pi-check',
|
||||
acceptClass: 'p-button-success',
|
||||
accept: () => doSoftDelete(row, true)
|
||||
});
|
||||
}
|
||||
|
||||
async function doSoftDelete(row, reactivate) {
|
||||
try {
|
||||
const { error } = await supabase.from('features').delete().eq('id', row.id);
|
||||
const { error } = await supabase.from('features').update({ is_active: !!reactivate }).eq('id', row.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso excluído.', life: 2500 });
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Ok',
|
||||
detail: reactivate ? 'Recurso reativado.' : 'Recurso depreciado.',
|
||||
life: 2500
|
||||
});
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
const hint = isFkViolation(e) ? 'Este recurso está vinculado a planos ou módulos. Remova o vínculo antes de excluir.' : '';
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: hint ? `${e?.message} — ${hint}` : e?.message || String(e),
|
||||
detail: e?.message || String(e),
|
||||
life: 5200
|
||||
});
|
||||
}
|
||||
@@ -288,7 +305,7 @@ onBeforeUnmount(() => {
|
||||
<!-- content -->
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<FloatLabel variant="on" class="w-full md:w-[380px]">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
@@ -296,6 +313,10 @@ onBeforeUnmount(() => {
|
||||
</IconField>
|
||||
<label for="features_search">Buscar por key, nome ou descrição</label>
|
||||
</FloatLabel>
|
||||
<label class="inline-flex items-center gap-2 text-[0.95rem] text-[var(--text-color-secondary)] cursor-pointer">
|
||||
<input type="checkbox" v-model="showInactive" class="accent-current" />
|
||||
Mostrar depreciados
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
||||
@@ -330,11 +351,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<Column field="created_at" header="Criado em" sortable style="width: 13rem" />
|
||||
|
||||
<Column header="Ações" style="width: 10rem">
|
||||
<Column header="Status" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.is_active === false ? 'depreciado' : 'ativo'" :severity="data.is_active === false ? 'warn' : 'success'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
|
||||
<Button v-if="data.is_active !== false" icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" v-tooltip.top="'Depreciar'" />
|
||||
<Button v-else icon="pi pi-replay" severity="success" outlined @click="askReactivate(data)" v-tooltip.top="'Reativar'" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -354,32 +354,26 @@ function askDelete(row) {
|
||||
});
|
||||
}
|
||||
|
||||
async function disableActivePrices(planId) {
|
||||
const nowIso = new Date().toISOString();
|
||||
const { error } = await supabase.from('plan_prices').update({ is_active: false, active_to: nowIso }).eq('plan_id', planId).eq('is_active', true);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async function doDelete(row) {
|
||||
try {
|
||||
await disableActivePrices(row.id);
|
||||
|
||||
const { error } = await supabase.from('plans').delete().eq('id', row.id);
|
||||
|
||||
// V#36: RPC delete_plan_safe valida subscriptions ativas atomicamente
|
||||
// (desativa prices + deleta plan no mesmo batch). Bloqueia se houver
|
||||
// subscriptions ativas para impedir órfãos.
|
||||
const { error } = await supabase.rpc('delete_plan_safe', { p_plan_id: row.id });
|
||||
if (error) throw error;
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Plano excluído.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
const hint = isFkViolation(e) ? 'Esse plano ainda está referenciado (ex.: plan_features, subscriptions ou pricing). Remova vínculos antes de excluir.' : '';
|
||||
const isBusy = msg.toLowerCase().includes('assinatura') && msg.toLowerCase().includes('ativa');
|
||||
const hint = isFkViolation(e) ? 'Esse plano ainda está referenciado (ex.: plan_features ou pricing). Remova vínculos antes de excluir.' : '';
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
severity: isBusy ? 'warn' : 'error',
|
||||
summary: isBusy ? 'Plano em uso' : 'Erro',
|
||||
detail: hint ? `${msg} — ${hint}` : msg,
|
||||
life: 5200
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SaaS / Segurança (A#20 rev2)
|
||||
|--------------------------------------------------------------------------
|
||||
| Painel saas_admin para configurar a defesa em camadas e ver telemetria.
|
||||
|
|
||||
| Tabela: saas_security_config (singleton)
|
||||
| Logs: public_submission_attempts (genérico, todos endpoints)
|
||||
| Estado: submission_rate_limits (IPs com contadores e bloqueios)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
import AccordionContent from 'primevue/accordioncontent';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const helpOpen = ref(true); // accordion abre fechado depois da primeira leitura
|
||||
|
||||
const cfg = ref(null);
|
||||
const cfgLoading = ref(false);
|
||||
const cfgSaving = ref(false);
|
||||
|
||||
const stats = ref({ total24h: 0, blocked24h: 0, success24h: 0, captcha24h: 0 });
|
||||
const recentAttempts = ref([]);
|
||||
const blockedIps = ref([]);
|
||||
const dataLoading = ref(false);
|
||||
|
||||
async function loadConfig() {
|
||||
cfgLoading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('saas_security_config').select('*').eq('id', true).maybeSingle();
|
||||
if (error) throw error;
|
||||
cfg.value = data || null;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha config', life: 4000 });
|
||||
} finally {
|
||||
cfgLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!cfg.value) return;
|
||||
cfgSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
honeypot_enabled: cfg.value.honeypot_enabled,
|
||||
rate_limit_enabled: cfg.value.rate_limit_enabled,
|
||||
rate_limit_window_min: cfg.value.rate_limit_window_min,
|
||||
rate_limit_max_attempts: cfg.value.rate_limit_max_attempts,
|
||||
captcha_after_failures: cfg.value.captcha_after_failures,
|
||||
captcha_required_globally: cfg.value.captcha_required_globally,
|
||||
block_duration_min: cfg.value.block_duration_min,
|
||||
captcha_required_window_min: cfg.value.captcha_required_window_min
|
||||
};
|
||||
const { error } = await supabase.from('saas_security_config').update(payload).eq('id', true);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada.', life: 2500 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha ao salvar', life: 4000 });
|
||||
} finally {
|
||||
cfgSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
dataLoading.value = true;
|
||||
try {
|
||||
const since = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
||||
|
||||
const [{ data: attempts, error: e1 }, { data: blocked, error: e2 }] = await Promise.all([
|
||||
supabase
|
||||
.from('public_submission_attempts')
|
||||
.select('id, endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, created_at')
|
||||
.gte('created_at', since)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100),
|
||||
supabase
|
||||
.from('submission_rate_limits')
|
||||
.select('*')
|
||||
.order('last_attempt_at', { ascending: false })
|
||||
.limit(50)
|
||||
]);
|
||||
|
||||
if (e1) throw e1;
|
||||
if (e2) throw e2;
|
||||
|
||||
recentAttempts.value = attempts || [];
|
||||
blockedIps.value = blocked || [];
|
||||
|
||||
const total = (attempts || []).length;
|
||||
const success = (attempts || []).filter((a) => a.success).length;
|
||||
const blockedC = (attempts || []).filter((a) => !a.success && a.blocked_by && a.blocked_by !== 'rpc').length;
|
||||
const captchaC = (attempts || []).filter((a) => a.blocked_by === 'captcha').length;
|
||||
|
||||
stats.value = { total24h: total, blocked24h: blockedC, success24h: success, captcha24h: captchaC };
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha dashboard', life: 4000 });
|
||||
} finally {
|
||||
dataLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function blockedSeverity(by) {
|
||||
return ({
|
||||
honeypot: 'info',
|
||||
rate_limit: 'warn',
|
||||
captcha: 'warn',
|
||||
validation: 'secondary',
|
||||
rpc: 'danger'
|
||||
})[by] || 'secondary';
|
||||
}
|
||||
|
||||
function fmtRelative(ts) {
|
||||
if (!ts) return '—';
|
||||
const diff = (Date.now() - new Date(ts).getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s atrás`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}min atrás`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h atrás`;
|
||||
return new Date(ts).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
const ipsCurrentlyBlocked = computed(() => {
|
||||
const now = Date.now();
|
||||
return blockedIps.value.filter((r) => r.blocked_until && new Date(r.blocked_until).getTime() > now);
|
||||
});
|
||||
|
||||
const ipsRequiringCaptcha = computed(() => {
|
||||
const now = Date.now();
|
||||
return blockedIps.value.filter((r) => r.requires_captcha_until && new Date(r.requires_captcha_until).getTime() > now);
|
||||
});
|
||||
|
||||
async function clearRateLimit(row) {
|
||||
confirm.require({
|
||||
message: `Limpar rate limit de ${row.ip_hash.slice(0, 8)}… em ${row.endpoint}?`,
|
||||
header: 'Limpar bloqueio',
|
||||
icon: 'pi pi-question-circle',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('submission_rate_limits').delete().eq('ip_hash', row.ip_hash).eq('endpoint', row.endpoint);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Bloqueio limpo.', life: 2500 });
|
||||
await loadDashboard();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha', life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadConfig(), loadDashboard()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight">Segurança — defesa contra bots</div>
|
||||
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">Configuração da defesa em camadas para endpoints públicos (cadastro de paciente, signup, agendador).</div>
|
||||
</div>
|
||||
|
||||
<!-- Card explicativo (colapsável) -->
|
||||
<div class="rounded-md border border-indigo-400/30 bg-indigo-400/5">
|
||||
<Accordion :value="helpOpen ? '0' : null" @update:value="(v) => helpOpen = (v === '0')">
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-info-circle text-indigo-400" />
|
||||
<span class="font-semibold">Como funciona — guia de uso</span>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-col gap-5 text-[0.92rem] leading-relaxed">
|
||||
|
||||
<!-- ── Visão geral ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-shield mr-2 text-emerald-400" />
|
||||
Visão geral
|
||||
</div>
|
||||
<p class="text-[var(--text-color-secondary)]">
|
||||
Toda submissão na página pública (cadastro de paciente externo) passa por <b>5 camadas em ordem</b>. Se qualquer uma rejeitar, o submit é bloqueado e logado.
|
||||
O design prioriza <b>zero fricção pro paciente legítimo</b> — captcha visível só aparece quando o sistema desconfia.
|
||||
</p>
|
||||
<ol class="list-decimal pl-6 mt-3 space-y-1.5 text-[var(--text-color-secondary)]">
|
||||
<li><b>Honeypot</b> — campo invisível que humano nunca vê. Bot scraper preenche tudo e cai aqui. <span class="opacity-70">(zero fricção)</span></li>
|
||||
<li><b>Validação básica</b> — payload obrigatório, token presente.</li>
|
||||
<li><b>Rate limit por IP</b> — N tentativas por janela de tempo, do mesmo IP+endpoint. Excedeu, bloqueia por X min.</li>
|
||||
<li><b>Math captcha condicional</b> — só ativa se o IP teve N falhas recentes. Mostra "Quanto é 7+4?".</li>
|
||||
<li><b>RPC do intake</b> — só executa se passou pelas camadas 1-4. Validação final dos dados.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ── Cada controle ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-sliders-h mr-2 text-amber-400" />
|
||||
O que cada controle faz
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Honeypot</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Toggle que liga/desliga o campo invisível. Default <b>ON</b>. Praticamente sem custo, mantenha sempre ligado.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Rate limit</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Toggle mestre. Se desligar, ninguém é bloqueado por excesso. Default <b>ON</b>.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-rose-400/40 p-3">
|
||||
<div class="font-medium text-rose-300">Modo paranoid</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
Liga: <b>todo submit exige captcha</b>, mesmo IPs novos. Use SÓ sob ataque ativo (você verá centenas de tentativas no dashboard).
|
||||
Pacientes legítimos vão precisar resolver math toda vez. Lembre de desligar quando passar.
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Janela (min)</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Período de medição. Default <b>10</b>. Tentativas mais antigas que isso são esquecidas.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Máx tentativas na janela</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Quantos submits do mesmo IP toleramos antes de bloquear. Default <b>5</b>. Aumentar se for ambiente compartilhado (ex: WiFi de clínica com vários terapeutas).</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Falhas até captcha</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Após N <b>falhas</b> (não tentativas), o IP passa a precisar resolver math. Default <b>3</b>. Mais baixo = mais paranoico.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Duração do bloqueio</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Quanto tempo o IP fica bloqueado depois de exceder o máximo. Default <b>30 min</b>.</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div class="font-medium">Janela do captcha condicional</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Por quanto tempo o IP suspeito continua precisando de captcha após disparar. Default <b>60 min</b>.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Como testar local ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-desktop mr-2 text-cyan-400" />
|
||||
Como testar localmente
|
||||
</div>
|
||||
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
|
||||
<li>
|
||||
Suba a edge function:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase functions serve submit-patient-intake</pre>
|
||||
</li>
|
||||
<li>
|
||||
Logue como terapeuta, gere um link de cadastro de paciente em
|
||||
<span class="font-mono text-xs">/admin/clinic/intake</span>, copie o link.
|
||||
</li>
|
||||
<li>
|
||||
Abra o link em uma <b>aba anônima</b> (pra simular paciente). Preencha e envie. Caminho feliz: zero fricção, vai cair em <span class="font-mono text-xs">patient_intake_requests</span>.
|
||||
</li>
|
||||
<li>
|
||||
Pra simular bot: abra DevTools → Console e dispare 4 submits inválidos:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">for (let i=0; i<4; i++) {
|
||||
await fetch('http://127.0.0.1:54321/functions/v1/submit-patient-intake', {
|
||||
method: 'POST',
|
||||
headers: {'content-type':'application/json', apikey: '<ANON_KEY>'},
|
||||
body: JSON.stringify({ token: 'ruim', payload: {} })
|
||||
})
|
||||
}</pre>
|
||||
</li>
|
||||
<li>Recarregue esta tela: você verá o IP listado em <b>"IPs ativos"</b> com tag amarela <b>"exige captcha"</b>. Próximo submit do mesmo IP terá o math captcha visível.</li>
|
||||
<li>Pra simular bloqueio total: faça 6+ tentativas. IP fica com tag vermelha <b>"bloqueado"</b> por 30 min. Botão <i class="pi pi-eraser" /> remove o bloqueio manualmente.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ── Como testar online ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-cloud mr-2 text-emerald-400" />
|
||||
Como testar em produção
|
||||
</div>
|
||||
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
|
||||
<li>
|
||||
Deploy da edge function:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase functions deploy submit-patient-intake</pre>
|
||||
</li>
|
||||
<li>Sem configuração de chave externa — defesa é 100% self-hosted, funciona já após o deploy.</li>
|
||||
<li>
|
||||
Crie um link de teste no painel de terapeuta. Acesse de um celular (rede 4G — IP diferente do WiFi) para simular paciente real.
|
||||
</li>
|
||||
<li>Tente fluxo correto + tente abusar (4 submits errados). Volte aqui pra ver os logs em <b>"Tentativas recentes"</b>.</li>
|
||||
<li>Se notar IP brasileiro legítimo sendo bloqueado falsamente, aumente <b>"Máx tentativas na janela"</b> ou diminua <b>"Falhas até captcha"</b>.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ── Quando usar paranoid ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-exclamation-triangle mr-2 text-rose-400" />
|
||||
Quando ligar o modo paranoid
|
||||
</div>
|
||||
<p class="text-[var(--text-color-secondary)]">
|
||||
Use SÓ se você ver no dashboard <b>centenas de tentativas/hora</b> de IPs distintos (ataque distribuído real).
|
||||
Como cada paciente legítimo vai ter que resolver math toda vez, é fricção alta — só vale se a alternativa for floods de cadastro fake.
|
||||
</p>
|
||||
<p class="text-[var(--text-color-secondary)] mt-2">
|
||||
<b>Lembre de desligar</b> quando o ataque cessar (24-48h depois normalmente). Defina lembrete.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── O que NÃO protege ── -->
|
||||
<section>
|
||||
<div class="font-semibold text-[1rem] mb-2">
|
||||
<i class="pi pi-info-circle mr-2 text-amber-400" />
|
||||
Limitações honestas
|
||||
</div>
|
||||
<ul class="list-disc pl-6 space-y-1 text-[var(--text-color-secondary)]">
|
||||
<li>Bot <b>dedicado</b> com proxy rotativo + LLM resolvendo math <b>passa</b>. Mas o custo do ataque sobe muito.</li>
|
||||
<li>Vários pacientes na mesma rede (clínica com WiFi compartilhado) podem disparar rate limit. Se acontecer, ajuste o slider.</li>
|
||||
<li>Math captcha não é acessível pra todos (deficiência cognitiva, dislexia severa). Se for crítico, diminua <b>"Falhas até captcha"</b> alto pra maior tolerância.</li>
|
||||
<li>Logs em <span class="font-mono text-xs">public_submission_attempts</span> crescem indefinidamente. Configure cron de limpeza após 30-90 dias.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Submits 24h</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ stats.total24h }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Bem-sucedidos</div>
|
||||
<div class="text-2xl font-bold mt-1 text-emerald-500">{{ stats.success24h }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Bloqueados (bot)</div>
|
||||
<div class="text-2xl font-bold mt-1 text-amber-500">{{ stats.blocked24h }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Captchas barrados</div>
|
||||
<div class="text-2xl font-bold mt-1 text-rose-500">{{ stats.captcha24h }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuração -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold mb-4">Configuração global</div>
|
||||
|
||||
<div v-if="cfg" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Honeypot</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Campo invisível que bots preenchem.</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.honeypot_enabled" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Rate limit</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Limite de submits por IP por janela.</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.rate_limit_enabled" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-rose-400/40 bg-rose-400/5 p-3 md:col-span-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-rose-300">Modo paranoid</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Liga: TODO submit exige captcha, mesmo IP novo. Use só sob ataque ativo.</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.captcha_required_globally" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Janela (min)</label>
|
||||
<InputNumber v-model="cfg.rate_limit_window_min" :min="1" :max="1440" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Máx tentativas na janela</label>
|
||||
<InputNumber v-model="cfg.rate_limit_max_attempts" :min="1" :max="100" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Falhas até exigir captcha</label>
|
||||
<InputNumber v-model="cfg.captcha_after_failures" :min="1" :max="20" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Duração do bloqueio (min)</label>
|
||||
<InputNumber v-model="cfg.block_duration_min" :min="1" :max="1440" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Janela do captcha condicional (min)</label>
|
||||
<InputNumber v-model="cfg.captcha_required_window_min" :min="1" :max="1440" class="w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<Button label="Recarregar" icon="pi pi-refresh" outlined @click="loadConfig" :disabled="cfgSaving" />
|
||||
<Button label="Salvar" icon="pi pi-check" @click="saveConfig" :loading="cfgSaving" :disabled="!cfg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPs em estado ativo -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-semibold">IPs ativos (rate limit)</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ ipsCurrentlyBlocked.length }} bloqueados · {{ ipsRequiringCaptcha.length }} exigindo captcha
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :value="blockedIps" :loading="dataLoading" size="small" stripedRows responsiveLayout="scroll">
|
||||
<Column header="IP">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.ip_hash?.slice(0, 12) }}…</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="endpoint" header="Endpoint" />
|
||||
<Column header="Tentativas" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
{{ data.fail_count }}/{{ data.attempt_count }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Estado">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.blocked_until && new Date(data.blocked_until) > new Date()" value="bloqueado" severity="danger" />
|
||||
<Tag v-else-if="data.requires_captcha_until && new Date(data.requires_captcha_until) > new Date()" value="exige captcha" severity="warn" />
|
||||
<Tag v-else value="ok" severity="success" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Última">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ fmtRelative(data.last_attempt_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Ações" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-eraser" text size="small" @click="clearRateLimit(data)" v-tooltip.top="'Limpar bloqueio'" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Tentativas recentes -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-semibold">Tentativas recentes (24h)</div>
|
||||
<Button icon="pi pi-refresh" text size="small" @click="loadDashboard" :loading="dataLoading" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="recentAttempts" :loading="dataLoading" size="small" stripedRows responsiveLayout="scroll" :paginator="true" :rows="20">
|
||||
<Column header="Quando">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ fmtRelative(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="endpoint" header="Endpoint" />
|
||||
<Column header="IP">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs opacity-70">{{ data.ip_hash?.slice(0, 8) || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Estado" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.success" value="ok" severity="success" />
|
||||
<Tag v-else :value="data.blocked_by || 'erro'" :severity="blockedSeverity(data.blocked_by)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="error_code" header="Motivo">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ data.error_code || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,8 +19,11 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { createSupportSession, listActiveSupportSessions, listSessionHistory, revokeSupportSession, buildSupportUrl } from '@/support/supportSessionService';
|
||||
import { logEvent, logError } from '@/support/supportLogger';
|
||||
|
||||
const TAG = '[SaasSupportPage]';
|
||||
const TAG = 'SaasSupportPage';
|
||||
const isDev = import.meta.env.DEV;
|
||||
const dev = (msg, data) => { if (isDev) logEvent(TAG, msg, data); };
|
||||
const toast = useToast();
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
@@ -84,7 +87,7 @@ const activeSessionCount = computed(() => activeSessions.value.length);
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
console.log(`${TAG} montado`);
|
||||
dev('mounted');
|
||||
await loadTenants();
|
||||
await loadActiveSessions();
|
||||
startTick();
|
||||
@@ -93,14 +96,13 @@ onMounted(async () => {
|
||||
// ── Tenants ───────────────────────────────────────────────────────────────────
|
||||
async function loadTenants() {
|
||||
loadingTenants.value = true;
|
||||
console.log(`${TAG} loadTenants`);
|
||||
try {
|
||||
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const list = data || [];
|
||||
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`);
|
||||
dev(`${list.length} tenant(s) carregado(s)`);
|
||||
|
||||
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
|
||||
tenants.value = list.map((t) => ({
|
||||
@@ -108,7 +110,7 @@ async function loadTenants() {
|
||||
label: `${t.name} (${t.kind ?? 'tenant'})`
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadTenants ERRO`, e);
|
||||
logError(TAG, 'loadTenants', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingTenants.value = false;
|
||||
@@ -118,12 +120,11 @@ async function loadTenants() {
|
||||
// ── Sessões ativas ─────────────────────────────────────────────────────────────
|
||||
async function loadActiveSessions() {
|
||||
loadingSessions.value = true;
|
||||
console.log(`${TAG} loadActiveSessions`);
|
||||
try {
|
||||
activeSessions.value = await listActiveSupportSessions();
|
||||
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`);
|
||||
dev(`${activeSessions.value.length} sessão(ões) ativa(s)`);
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadActiveSessions ERRO`, e);
|
||||
logError(TAG, 'loadActiveSessions', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingSessions.value = false;
|
||||
@@ -134,12 +135,11 @@ async function loadActiveSessions() {
|
||||
async function loadHistory() {
|
||||
if (loadingHistory.value) return;
|
||||
loadingHistory.value = true;
|
||||
console.log(`${TAG} loadHistory`);
|
||||
try {
|
||||
sessionHistory.value = await listSessionHistory(100);
|
||||
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`);
|
||||
dev(`histórico: ${sessionHistory.value.length} registro(s)`);
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadHistory ERRO`, e);
|
||||
logError(TAG, 'loadHistory', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingHistory.value = false;
|
||||
@@ -153,19 +153,17 @@ async function handleCreate() {
|
||||
generatedUrl.value = null;
|
||||
generatedData.value = null;
|
||||
|
||||
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' });
|
||||
dev('handleCreate', { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value });
|
||||
|
||||
try {
|
||||
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value);
|
||||
generatedData.value = result;
|
||||
generatedUrl.value = buildSupportUrl(result.token);
|
||||
|
||||
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0, 8)}…`, expires_at: result.expires_at });
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 });
|
||||
await loadActiveSessions();
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleCreate ERRO`, e);
|
||||
logError(TAG, 'handleCreate', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 });
|
||||
} finally {
|
||||
creating.value = false;
|
||||
@@ -175,7 +173,6 @@ async function handleCreate() {
|
||||
// ── Revogar ───────────────────────────────────────────────────────────────────
|
||||
async function handleRevoke(token) {
|
||||
revokingToken.value = token;
|
||||
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}…`);
|
||||
try {
|
||||
await revokeSupportSession(token);
|
||||
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 });
|
||||
@@ -186,7 +183,7 @@ async function handleRevoke(token) {
|
||||
await loadActiveSessions();
|
||||
if (sessionHistory.value.length) await loadHistory();
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleRevoke ERRO`, e);
|
||||
logError(TAG, 'handleRevoke', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
revokingToken.value = null;
|
||||
@@ -197,7 +194,6 @@ async function handleRevoke(token) {
|
||||
function copyUrl(url) {
|
||||
if (!url) return;
|
||||
navigator.clipboard.writeText(url);
|
||||
console.log(`${TAG} URL copiada`);
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 });
|
||||
}
|
||||
|
||||
@@ -205,7 +201,6 @@ function copyUrl(url) {
|
||||
function onTabChange(e) {
|
||||
const idx = e.index ?? e;
|
||||
activeTab.value = idx;
|
||||
console.log(`${TAG} tab mudou para ${idx}`);
|
||||
if (idx === 2 && sessionHistory.value.length === 0) loadHistory();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/saas/SaasTenantFeaturesPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import Select from 'primevue/select';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const tenants = ref([]);
|
||||
const features = ref([]);
|
||||
const selectedTenantId = ref(null);
|
||||
|
||||
const planAllowed = ref(new Set());
|
||||
const planKey = ref(null);
|
||||
const overrides = ref({});
|
||||
const exceptionsLog = ref([]);
|
||||
|
||||
const filterText = ref('');
|
||||
|
||||
const dlg = ref({
|
||||
open: false,
|
||||
feature: null,
|
||||
nextEnabled: false,
|
||||
isException: false,
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const selectedTenant = computed(() => tenants.value.find((t) => t.id === selectedTenantId.value) || null);
|
||||
|
||||
const rows = computed(() => {
|
||||
const ft = String(filterText.value || '').trim().toLowerCase();
|
||||
return features.value
|
||||
.filter((f) => !ft || f.key.toLowerCase().includes(ft) || (f.name || '').toLowerCase().includes(ft))
|
||||
.map((f) => {
|
||||
const inPlan = planAllowed.value.has(f.key);
|
||||
const ov = overrides.value[f.key];
|
||||
let status = 'inactive';
|
||||
if (ov === true) status = inPlan ? 'active_plan' : 'exception';
|
||||
else if (ov === false) status = 'off_pref';
|
||||
else status = inPlan ? 'active_plan' : 'inactive';
|
||||
return { ...f, in_plan: inPlan, override: ov, status };
|
||||
});
|
||||
});
|
||||
|
||||
function statusLabel(s) {
|
||||
return (
|
||||
{
|
||||
active_plan: 'Ativa (plano)',
|
||||
off_pref: 'Desligada (preferência)',
|
||||
exception: 'Exceção comercial',
|
||||
inactive: 'Não disponível'
|
||||
}[s] || s
|
||||
);
|
||||
}
|
||||
|
||||
function statusSeverity(s) {
|
||||
return (
|
||||
{
|
||||
active_plan: 'success',
|
||||
off_pref: 'warn',
|
||||
exception: 'info',
|
||||
inactive: 'secondary'
|
||||
}[s] || 'secondary'
|
||||
);
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
const { data, error } = await supabase.from('tenants').select('id, name').order('name', { ascending: true });
|
||||
if (error) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar clínicas', life: 4000 });
|
||||
return;
|
||||
}
|
||||
tenants.value = data || [];
|
||||
}
|
||||
|
||||
async function loadFeatures() {
|
||||
const { data, error } = await supabase.from('features').select('id, key, name, descricao').order('key', { ascending: true });
|
||||
if (error) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar catálogo', life: 4000 });
|
||||
return;
|
||||
}
|
||||
features.value = data || [];
|
||||
}
|
||||
|
||||
async function loadTenantState(tenantId) {
|
||||
if (!tenantId) {
|
||||
planAllowed.value = new Set();
|
||||
planKey.value = null;
|
||||
overrides.value = {};
|
||||
exceptionsLog.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const [{ data: ent, error: e1 }, { data: ovr, error: e2 }, { data: sub, error: e3 }, { data: log, error: e4 }] = await Promise.all([
|
||||
supabase.from('v_tenant_entitlements').select('feature_key').eq('tenant_id', tenantId),
|
||||
supabase.from('tenant_features').select('feature_key, enabled').eq('tenant_id', tenantId),
|
||||
supabase.from('v_tenant_active_subscription').select('plan_key').eq('tenant_id', tenantId).maybeSingle(),
|
||||
supabase.from('tenant_feature_exceptions_log').select('feature_key, enabled, reason, created_by, created_at').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(50)
|
||||
]);
|
||||
|
||||
if (e1) throw e1;
|
||||
if (e2) throw e2;
|
||||
if (e3) throw e3;
|
||||
if (e4) throw e4;
|
||||
|
||||
const set = new Set();
|
||||
for (const r of ent || []) set.add(r.feature_key);
|
||||
planAllowed.value = set;
|
||||
|
||||
const map = {};
|
||||
for (const r of ovr || []) map[r.feature_key] = !!r.enabled;
|
||||
overrides.value = map;
|
||||
|
||||
planKey.value = sub?.plan_key || null;
|
||||
exceptionsLog.value = log || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar tenant', detail: e?.message || 'falha', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(feature, nextEnabled) {
|
||||
const isException = nextEnabled === true && !planAllowed.value.has(feature.key);
|
||||
dlg.value = {
|
||||
open: true,
|
||||
feature,
|
||||
nextEnabled,
|
||||
isException,
|
||||
reason: ''
|
||||
};
|
||||
}
|
||||
|
||||
async function confirmChange() {
|
||||
const { feature, nextEnabled, isException } = dlg.value;
|
||||
const reason = String(dlg.value.reason || '').trim();
|
||||
|
||||
if (isException && reason.length < 4) {
|
||||
toast.add({ severity: 'warn', summary: 'Motivo obrigatório', detail: 'Exceção comercial exige motivo (≥ 4 caracteres).', life: 3500 });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.rpc('set_tenant_feature_exception', {
|
||||
p_tenant_id: selectedTenantId.value,
|
||||
p_feature_key: feature.key,
|
||||
p_enabled: nextEnabled,
|
||||
p_reason: reason || null
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Atualizado',
|
||||
detail: `${feature.name || feature.key}: ${nextEnabled ? 'ativada' : 'desativada'}${isException ? ' (exceção)' : ''}`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
dlg.value.open = false;
|
||||
await loadTenantState(selectedTenantId.value);
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'erro ao alterar feature', life: 4500 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearOverride(feature) {
|
||||
// "Limpar override" = setar conforme o plano (volta ao padrão)
|
||||
const back = planAllowed.value.has(feature.key);
|
||||
openDialog(feature, back);
|
||||
dlg.value.reason = 'Limpeza de override (volta ao padrão do plano)';
|
||||
}
|
||||
|
||||
watch(selectedTenantId, async (id) => {
|
||||
await loadTenantState(id);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTenants(), loadFeatures()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos por Clínica</div>
|
||||
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">Gerencia overrides de features por tenant. Exceções comerciais (ativar feature fora do plano) exigem motivo e ficam logadas.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="text-[0.85rem] text-[var(--text-color-secondary)]">Clínica</label>
|
||||
<Select v-model="selectedTenantId" :options="tenants" optionLabel="name" optionValue="id" placeholder="Selecione…" class="w-full mt-1" filter showClear />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-end">
|
||||
<div v-if="selectedTenant" class="text-[0.95rem]">
|
||||
<span class="text-[var(--text-color-secondary)]">Plano ativo:</span>
|
||||
<Tag :value="planKey || 'sem assinatura'" :severity="planKey ? 'info' : 'warn'" class="ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTenantId" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div class="font-semibold">Catálogo de features</div>
|
||||
<InputText v-model="filterText" placeholder="filtrar por chave ou nome…" class="max-w-xs" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="rows" :loading="loading" dataKey="id" responsiveLayout="scroll" size="small" stripedRows>
|
||||
<Column field="key" header="Chave" sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-[0.85rem]">{{ data.key }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Nome" sortable />
|
||||
<Column header="No plano">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.in_plan ? 'sim' : 'não'" :severity="data.in_plan ? 'success' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Estado">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Ações" style="width: 320px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button v-if="data.status === 'active_plan'" label="Desligar (preferência)" icon="pi pi-times" size="small" severity="warn" outlined @click="openDialog(data, false)" />
|
||||
|
||||
<Button v-if="data.status === 'off_pref'" label="Religar" icon="pi pi-check" size="small" severity="success" outlined @click="openDialog(data, true)" />
|
||||
|
||||
<Button v-if="data.status === 'inactive'" label="Liberar exceção" icon="pi pi-key" size="small" severity="info" outlined @click="openDialog(data, true)" />
|
||||
|
||||
<Button v-if="data.status === 'exception'" label="Cancelar exceção" icon="pi pi-undo" size="small" severity="danger" outlined @click="openDialog(data, false)" />
|
||||
|
||||
<Button v-if="data.override !== undefined" label="Limpar override" icon="pi pi-eraser" size="small" text @click="clearOverride(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTenantId && exceptionsLog.length" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold mb-3">Histórico de mudanças (50 mais recentes)</div>
|
||||
<DataTable :value="exceptionsLog" size="small" stripedRows responsiveLayout="scroll">
|
||||
<Column header="Quando">
|
||||
<template #body="{ data }">{{ new Date(data.created_at).toLocaleString('pt-BR') }}</template>
|
||||
</Column>
|
||||
<Column field="feature_key" header="Feature">
|
||||
<template #body="{ data }"><span class="font-mono text-[0.85rem]">{{ data.feature_key }}</span></template>
|
||||
</Column>
|
||||
<Column header="Estado">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.enabled ? 'on' : 'off'" :severity="data.enabled ? 'success' : 'warn'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="reason" header="Motivo">
|
||||
<template #body="{ data }">
|
||||
<span :class="!data.reason && 'opacity-50 italic'">{{ data.reason || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_by" header="Por">
|
||||
<template #body="{ data }"><span class="font-mono text-[0.75rem] opacity-70">{{ data.created_by?.slice(0, 8) || '—' }}</span></template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="dlg.open" :header="`${dlg.nextEnabled ? 'Ativar' : 'Desativar'}: ${dlg.feature?.name || dlg.feature?.key}`" modal :style="{ width: '480px' }" :closable="!saving">
|
||||
<div v-if="dlg.isException" class="rounded-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 mb-3 text-[0.9rem]">
|
||||
<i class="pi pi-exclamation-triangle text-amber-500 mr-2" />
|
||||
Esta feature <b>não está no plano</b> do tenant. Ao confirmar, vai ser registrada como <b>exceção comercial</b>.
|
||||
</div>
|
||||
|
||||
<label class="text-[0.85rem] text-[var(--text-color-secondary)]">Motivo {{ dlg.isException ? '(obrigatório)' : '(opcional)' }}</label>
|
||||
<Textarea v-model="dlg.reason" rows="3" class="w-full mt-1" :placeholder="dlg.isException ? 'Ex: Cliente pagou R$50/mês via PIX em 19/04' : 'Ex: Tenant desligou módulo por preferência'" autoResize />
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text :disabled="saving" @click="dlg.open = false" />
|
||||
<Button :label="dlg.nextEnabled ? 'Ativar' : 'Desativar'" :icon="dlg.nextEnabled ? 'pi pi-check' : 'pi pi-times'" :severity="dlg.isException ? 'info' : dlg.nextEnabled ? 'success' : 'warn'" :loading="saving" @click="confirmChange" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,346 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SaaS / Twilio Config
|
||||
|--------------------------------------------------------------------------
|
||||
| Painel para editar config operacional Twilio (account_sid, webhook URL,
|
||||
| cotação, margem) sem precisar de redeploy.
|
||||
|
|
||||
| AUTH_TOKEN continua em env var por segurança — painel só mostra status.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
import AccordionContent from 'primevue/accordioncontent';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const helpOpen = ref(true);
|
||||
|
||||
const form = ref({
|
||||
account_sid: '',
|
||||
whatsapp_webhook_url: '',
|
||||
usd_brl_rate: 5.5,
|
||||
margin_multiplier: 1.4,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const meta = ref({ updated_at: null, updated_by: null });
|
||||
|
||||
// Status do AUTH_TOKEN: tentamos uma chamada que NÃO mexe em nada (sync com canal inválido)
|
||||
// e checamos se a edge devolve "TWILIO_AUTH_TOKEN não configurado". Se sim, env tá vazia.
|
||||
const tokenStatus = ref('unknown'); // 'configured' | 'missing' | 'unknown'
|
||||
const tokenChecking = ref(false);
|
||||
|
||||
const sidValid = computed(() => !form.value.account_sid || /^AC[a-zA-Z0-9]{32}$/.test(form.value.account_sid));
|
||||
const urlValid = computed(() => !form.value.whatsapp_webhook_url || /^https?:\/\//.test(form.value.whatsapp_webhook_url));
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_twilio_config');
|
||||
if (error) throw error;
|
||||
if (data) {
|
||||
form.value = {
|
||||
account_sid: data.account_sid || '',
|
||||
whatsapp_webhook_url: data.whatsapp_webhook_url || '',
|
||||
usd_brl_rate: Number(data.usd_brl_rate) || 5.5,
|
||||
margin_multiplier: Number(data.margin_multiplier) || 1.4,
|
||||
notes: data.notes || ''
|
||||
};
|
||||
meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha config', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!sidValid.value) {
|
||||
toast.add({ severity: 'warn', summary: 'SID inválido', detail: 'O Account SID deve começar com AC seguido de 32 caracteres.', life: 4000 });
|
||||
return;
|
||||
}
|
||||
if (!urlValid.value) {
|
||||
toast.add({ severity: 'warn', summary: 'URL inválida', detail: 'O webhook deve começar com http:// ou https://', life: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('update_twilio_config', {
|
||||
p_account_sid: form.value.account_sid || null,
|
||||
p_whatsapp_webhook_url: form.value.whatsapp_webhook_url || null,
|
||||
p_usd_brl_rate: form.value.usd_brl_rate,
|
||||
p_margin_multiplier: form.value.margin_multiplier,
|
||||
p_notes: form.value.notes || null
|
||||
});
|
||||
if (error) throw error;
|
||||
if (data) meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada. A edge function lê do banco a cada chamada.', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha ao salvar', life: 4500 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTokenStatus() {
|
||||
tokenChecking.value = true;
|
||||
try {
|
||||
// Chamada inofensiva: action inválida → edge function ainda checa env vars antes
|
||||
// Se devolver "TWILIO_AUTH_TOKEN não configurado" → env vazia
|
||||
// Se devolver outro erro (ex: "action obrigatória" ou "Não autorizado") → env tem valor
|
||||
const { data, error } = await supabase.functions.invoke('twilio-whatsapp-provision', {
|
||||
body: { action: 'sync_usage' }
|
||||
});
|
||||
let msg = '';
|
||||
if (error) {
|
||||
try {
|
||||
const ctxBody = await error.context?.json?.();
|
||||
msg = ctxBody?.error || error.message || '';
|
||||
} catch {
|
||||
msg = error.message || '';
|
||||
}
|
||||
} else if (data?.error) {
|
||||
msg = data.error;
|
||||
}
|
||||
|
||||
if (/AUTH_TOKEN.*não configurado/i.test(msg)) {
|
||||
tokenStatus.value = 'missing';
|
||||
} else {
|
||||
// qualquer outro erro (incl. ACCOUNT_SID, "action", etc) → token está setado
|
||||
tokenStatus.value = 'configured';
|
||||
}
|
||||
} catch {
|
||||
tokenStatus.value = 'unknown';
|
||||
} finally {
|
||||
tokenChecking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenSeverity = computed(() => ({
|
||||
configured: 'success',
|
||||
missing: 'danger',
|
||||
unknown: 'secondary'
|
||||
})[tokenStatus.value]);
|
||||
|
||||
const tokenLabel = computed(() => ({
|
||||
configured: 'configurado ✓',
|
||||
missing: 'não configurado',
|
||||
unknown: 'desconhecido (clique em "Verificar")'
|
||||
})[tokenStatus.value]);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight">Configuração Twilio</div>
|
||||
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Edite a config operacional sem redeploy. O <b>Auth Token</b> (secret) continua em variável de ambiente da Edge Function por segurança.
|
||||
</div>
|
||||
<div v-if="meta.updated_at" class="text-xs text-[var(--text-color-secondary)] mt-2">
|
||||
Última atualização: {{ new Date(meta.updated_at).toLocaleString('pt-BR') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card explicativo -->
|
||||
<div class="rounded-md border border-indigo-400/30 bg-indigo-400/5">
|
||||
<Accordion :value="helpOpen ? '0' : null" @update:value="(v) => helpOpen = (v === '0')">
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-info-circle text-indigo-400" />
|
||||
<span class="font-semibold">Como funciona — guia rápido</span>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-col gap-4 text-[0.92rem] leading-relaxed">
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Onde cada coisa fica</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="rounded-md border border-emerald-400/30 bg-emerald-400/5 p-3">
|
||||
<div class="font-medium text-emerald-300">No banco (editável aqui) ✏️</div>
|
||||
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
|
||||
<li><b>Account SID</b> — identificador público da conta</li>
|
||||
<li><b>Webhook URL</b> — endpoint que recebe callbacks do Twilio</li>
|
||||
<li><b>Cotação USD/BRL</b> — usada nos cálculos de custo</li>
|
||||
<li><b>Multiplicador de margem</b> — markup aplicado ao preço por mensagem</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-3">
|
||||
<div class="font-medium text-amber-300">Em env var (CLI obrigatório) 🔒</div>
|
||||
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
|
||||
<li><b>TWILIO_AUTH_TOKEN</b> — secret que envia mensagens</li>
|
||||
</ul>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-2">
|
||||
Mantido isolado pra reduzir superfície de ataque. Se vazar, atacante manda WhatsApp/SMS na sua conta até estourar limite.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Como configurar do zero</div>
|
||||
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
|
||||
<li>Crie a conta master em <a href="https://www.twilio.com/console" target="_blank" class="underline text-indigo-300">twilio.com/console</a> e copie o <b>Account SID</b> + <b>Auth Token</b>.</li>
|
||||
<li>
|
||||
Configure o secret na Edge Function:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=seu_token_aqui</pre>
|
||||
</li>
|
||||
<li>
|
||||
Cole o Account SID no campo abaixo (formato <code class="px-1 rounded bg-black/30 text-[11px]">AC + 32 chars</code>).
|
||||
</li>
|
||||
<li>
|
||||
Configure o webhook URL apontando pra sua função de webhook:
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">https://<projeto>.supabase.co/functions/v1/twilio-whatsapp-webhook</pre>
|
||||
</li>
|
||||
<li>Ajuste cotação USD/BRL e multiplicador de margem conforme sua estratégia comercial.</li>
|
||||
<li>Salve. As Edge Functions vão ler a config nova a cada chamada (sem redeploy).</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Por que o Auth Token não fica aqui</div>
|
||||
<p class="text-[var(--text-color-secondary)]">
|
||||
Decisão de segurança: dados editáveis no painel ficam no banco com RLS estrita (saas_admin). Mas o Auth Token é o único que dá poder de <b>gerar custos reais</b> (envio de SMS/WhatsApp). Mantê-lo isolado em env var significa que mesmo um vazamento do banco (backup público acidental, RLS bug, dump em dev) não compromete o token.
|
||||
</p>
|
||||
<p class="text-[var(--text-color-secondary)] mt-2">
|
||||
O painel mostra apenas <b>se o token está configurado</b> (✓/✗) — sem nunca ler ou expor o valor.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="font-semibold mb-2">Como testar depois de salvar</div>
|
||||
<ol class="list-decimal pl-6 space-y-1 text-[var(--text-color-secondary)]">
|
||||
<li>Vá em <span class="font-mono text-xs">/saas/twilio-whatsapp</span></li>
|
||||
<li>Clique em "Sincronizar uso" — deve devolver "0 canais atualizados" se ainda não há subcontas, sem erro.</li>
|
||||
<li>Provisione uma subconta de teste pra um tenant.</li>
|
||||
<li>Use o botão de envio de teste pra validar o pipeline completo.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<!-- Status do AUTH_TOKEN -->
|
||||
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-5">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-key text-amber-400" />
|
||||
TWILIO_AUTH_TOKEN (env var)
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
Secret que autentica todas as chamadas pra API do Twilio. Não armazenado no banco.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Tag :value="tokenLabel" :severity="tokenSeverity" />
|
||||
<Button label="Verificar" icon="pi pi-search" size="small" outlined :loading="tokenChecking" @click="checkTokenStatus" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
|
||||
<b>Pra trocar:</b>
|
||||
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=novo_token</pre>
|
||||
Depois redeploy as Edge Functions afetadas (ou aguarde restart automático).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold mb-4">Configuração editável</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Account SID (formato AC + 32 chars)</label>
|
||||
<InputText
|
||||
v-model="form.account_sid"
|
||||
placeholder="AC..."
|
||||
class="w-full mt-1"
|
||||
:invalid="!sidValid"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
<div v-if="!sidValid" class="text-xs text-rose-400 mt-1">SID precisa começar com AC seguido de 32 caracteres alfanuméricos.</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Webhook URL (callbacks de status do Twilio)</label>
|
||||
<InputText
|
||||
v-model="form.whatsapp_webhook_url"
|
||||
placeholder="https://..."
|
||||
class="w-full mt-1"
|
||||
:invalid="!urlValid"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
<div v-if="!urlValid" class="text-xs text-rose-400 mt-1">URL deve começar com http:// ou https://</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Cotação USD/BRL (1 USD vale)</label>
|
||||
<InputNumber
|
||||
v-model="form.usd_brl_rate"
|
||||
:min="0.01"
|
||||
:max="99.99"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="4"
|
||||
mode="decimal"
|
||||
class="w-full mt-1"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Multiplicador de margem (1.0 = sem margem)</label>
|
||||
<InputNumber
|
||||
v-model="form.margin_multiplier"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="4"
|
||||
mode="decimal"
|
||||
class="w-full mt-1"
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Notas internas (não aparece pro tenant)</label>
|
||||
<Textarea
|
||||
v-model="form.notes"
|
||||
rows="3"
|
||||
class="w-full mt-1"
|
||||
autoResize
|
||||
:disabled="loading || saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<Button label="Recarregar" icon="pi pi-refresh" outlined :disabled="saving" :loading="loading" @click="loadConfig" />
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="saving" :disabled="!sidValid || !urlValid" @click="saveConfig" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -234,7 +234,12 @@ async function syncAll() {
|
||||
const result = await store.syncUsageAll();
|
||||
toast.add({ severity: 'success', summary: 'Consumo sincronizado', detail: `${result.synced?.length ?? 0} canal(is) atualizados`, life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Não foi possível sincronizar',
|
||||
detail: e.message,
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ import Dropdown from 'primevue/dropdown';
|
||||
import Textarea from 'primevue/textarea';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const TAG = 'SubscriptionIntentsPage';
|
||||
|
||||
import { listSubscriptionIntents, markIntentPaid, cancelIntent, getSubscriptionForIntent } from '@/services/subscriptionIntents';
|
||||
|
||||
@@ -229,7 +232,7 @@ async function refresh() {
|
||||
});
|
||||
lastRefreshAt.value = new Date().toISOString();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'refresh', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -375,7 +378,7 @@ async function confirmAction() {
|
||||
showDialog.value = false;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'confirmAction', e);
|
||||
|
||||
const msg = e?.message || 'Falha ao atualizar.';
|
||||
const msgLower = String(msg).toLowerCase();
|
||||
@@ -449,7 +452,7 @@ async function openSubscriptionDialogFromIntent(intentRow) {
|
||||
subNotes.value = '';
|
||||
showSubDialog.value = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'openSubscriptionDialog', e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar assinatura.', life: 4500 });
|
||||
} finally {
|
||||
acting.value = false;
|
||||
@@ -547,7 +550,7 @@ async function confirmSubAction(toStatus) {
|
||||
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError(TAG, 'confirmSubAction', e);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevAuditoriaTab.vue — Bugs/débitos técnicos (CRUD + drag-drop)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import { useDraggableList, reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const items = ref([]);
|
||||
const filterStatus = ref('all');
|
||||
const filterSev = ref('all');
|
||||
const search = ref('');
|
||||
const openRow = ref(null);
|
||||
|
||||
// Drawer state
|
||||
const drawerOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
categoria: '',
|
||||
titulo: '',
|
||||
descricao_problema: '',
|
||||
solucao: '',
|
||||
severidade: 'medio',
|
||||
status: 'aberto',
|
||||
resolvido_em: null,
|
||||
sessao_resolucao: '',
|
||||
arquivo_afetado: '',
|
||||
tags: ''
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
aberto: { label: 'Aberto', color: '#ef4444', bg: 'rgba(239,68,68,.12)', icon: 'pi-exclamation-circle' },
|
||||
em_analise: { label: 'Em análise', color: '#f59e0b', bg: 'rgba(245,158,11,.12)', icon: 'pi-eye' },
|
||||
resolvido: { label: 'Resolvido', color: '#10b981', bg: 'rgba(16,185,129,.12)', icon: 'pi-check-circle' },
|
||||
wontfix: { label: "Won't fix", color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-ban' },
|
||||
duplicado: { label: 'Duplicado', color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-clone' }
|
||||
};
|
||||
|
||||
const SEV_LABEL = {
|
||||
critico: { label: 'Crítico', color: '#dc2626' },
|
||||
alto: { label: 'Alto', color: '#f59e0b' },
|
||||
medio: { label: 'Médio', color: '#0ea5e9' },
|
||||
baixo: { label: 'Baixo', color: '#94a3b8' }
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('dev_auditoria_items')
|
||||
.select('*')
|
||||
.order('ordem')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
items.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value;
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterSev.value !== 'all') list = list.filter((i) => i.severidade === filterSev.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.titulo.toLowerCase().includes(q) ||
|
||||
(i.descricao_problema || '').toLowerCase().includes(q) ||
|
||||
(i.categoria || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const counts = computed(() => ({
|
||||
total: items.value.length,
|
||||
aberto: items.value.filter((i) => ['aberto', 'em_analise'].includes(i.status)).length,
|
||||
resolvido: items.value.filter((i) => i.status === 'resolvido').length,
|
||||
critico: items.value.filter((i) => i.severidade === 'critico' && ['aberto', 'em_analise'].includes(i.status)).length
|
||||
}));
|
||||
|
||||
function toggle(id) {
|
||||
openRow.value = openRow.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editingId.value = item.id;
|
||||
form.value = {
|
||||
categoria: item.categoria || '',
|
||||
titulo: item.titulo || '',
|
||||
descricao_problema: item.descricao_problema || '',
|
||||
solucao: item.solucao || '',
|
||||
severidade: item.severidade || 'medio',
|
||||
status: item.status || 'aberto',
|
||||
resolvido_em: item.resolvido_em || null,
|
||||
sessao_resolucao: item.sessao_resolucao || '',
|
||||
arquivo_afetado: item.arquivo_afetado || '',
|
||||
tags: (item.tags || []).join(', ')
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.titulo.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Título obrigatório', life: 3000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
categoria: form.value.categoria.trim() || null,
|
||||
titulo: form.value.titulo.trim(),
|
||||
descricao_problema: form.value.descricao_problema.trim() || null,
|
||||
solucao: form.value.solucao.trim() || null,
|
||||
severidade: form.value.severidade || null,
|
||||
status: form.value.status,
|
||||
resolvido_em: form.value.resolvido_em || null,
|
||||
sessao_resolucao: form.value.sessao_resolucao.trim() || null,
|
||||
arquivo_afetado: form.value.arquivo_afetado.trim() || null,
|
||||
tags: form.value.tags
|
||||
? form.value.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
const { error } = await supabase.from('dev_auditoria_items').update(payload).eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (items.value.length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_auditoria_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
if (!editingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Tem certeza que quer excluir este item?',
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_auditoria_items').delete().eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorder(fromIdx, toIdx) {
|
||||
// Reordena apenas se não houver filtro ativo (senão os índices não batem com o DB)
|
||||
const hasFilter = filterStatus.value !== 'all' || filterSev.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
detail: 'Drag-drop só funciona com a lista completa.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
const before = items.value;
|
||||
items.value = reorderWithIndexes(before, fromIdx, toIdx);
|
||||
try {
|
||||
const updates = items.value.map((item) =>
|
||||
supabase.from('dev_auditoria_items').update({ ordem: item.ordem }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
} catch (e) {
|
||||
items.value = before; // rollback
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
const drag = useDraggableList({ onReorder: handleReorder });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="auditoria">
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">{{ counts.total }}</span>
|
||||
<span class="stat-lbl">total</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #ef4444">
|
||||
<span class="stat-num">{{ counts.aberto }}</span>
|
||||
<span class="stat-lbl">em aberto</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #10b981">
|
||||
<span class="stat-num">{{ counts.resolvido }}</span>
|
||||
<span class="stat-lbl">resolvidos</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #dc2626">
|
||||
<span class="stat-num">{{ counts.critico }}</span>
|
||||
<span class="stat-lbl">críticos abertos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar bug/débito..." class="filter-search" />
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="aberto">Aberto</option>
|
||||
<option value="em_analise">Em análise</option>
|
||||
<option value="resolvido">Resolvido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
<option value="duplicado">Duplicado</option>
|
||||
</select>
|
||||
<select v-model="filterSev" class="filter-sel">
|
||||
<option value="all">Todas severidades</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNew">
|
||||
<i class="pi pi-plus" /> Novo item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item com os filtros atuais.
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<article
|
||||
v-for="(item, idx) in itemsFiltered"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'audit-item',
|
||||
{ open: openRow === item.id, dragging: drag.dragIdx.value === idx, 'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx }
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<header class="audit-head">
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
<i
|
||||
:class="['pi', STATUS_LABEL[item.status]?.icon || 'pi-circle']"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color }"
|
||||
/>
|
||||
<div class="audit-title-wrap" @click="toggle(item.id)">
|
||||
<h4 class="audit-title">
|
||||
<span class="audit-ref">A#{{ item.id }}</span>
|
||||
{{ item.titulo }}
|
||||
</h4>
|
||||
<div class="audit-meta">
|
||||
<span v-if="item.categoria" class="audit-cat">{{ item.categoria }}</span>
|
||||
<span v-if="item.severidade" :style="{ color: SEV_LABEL[item.severidade]?.color }">
|
||||
{{ SEV_LABEL[item.severidade]?.label }}
|
||||
</span>
|
||||
<span v-if="item.arquivo_afetado" class="audit-file">
|
||||
<i class="pi pi-file text-xs" /> {{ item.arquivo_afetado }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="audit-status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="openEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<i
|
||||
:class="['pi pi-chevron-down audit-chev', { open: openRow === item.id }]"
|
||||
@click="toggle(item.id)"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div v-if="openRow === item.id" class="audit-body">
|
||||
<div v-if="item.descricao_problema" class="audit-section">
|
||||
<h5>Problema</h5>
|
||||
<p>{{ item.descricao_problema }}</p>
|
||||
</div>
|
||||
<div v-if="item.solucao" class="audit-section">
|
||||
<h5>Solução</h5>
|
||||
<p>{{ item.solucao }}</p>
|
||||
</div>
|
||||
<div v-if="item.sessao_resolucao || item.resolvido_em" class="audit-section">
|
||||
<h5>Resolução</h5>
|
||||
<p>
|
||||
<span v-if="item.sessao_resolucao">{{ item.sessao_resolucao }}</span>
|
||||
<span v-if="item.resolvido_em" class="text-[var(--text-color-secondary)]">
|
||||
— {{ formatDate(item.resolvido_em) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="audit-tags">
|
||||
<span v-for="tag in item.tags" :key="tag" class="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de edição -->
|
||||
<DevDrawer
|
||||
:open="drawerOpen"
|
||||
:title="editingId ? 'Editar bug/débito' : 'Novo bug/débito'"
|
||||
:subtitle="editingId ? `#${editingId}` : 'Preencha os campos e salve'"
|
||||
:can-save="!!form.titulo.trim()"
|
||||
:saving="saving"
|
||||
:danger="!!editingId"
|
||||
@close="drawerOpen = false"
|
||||
@save="save"
|
||||
@delete="askDelete"
|
||||
>
|
||||
<DevField label="Título" required>
|
||||
<input v-model="form.titulo" placeholder="Ex.: Memory leak no useRecurrence" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Status">
|
||||
<select v-model="form.status">
|
||||
<option value="aberto">Aberto</option>
|
||||
<option value="em_analise">Em análise</option>
|
||||
<option value="resolvido">Resolvido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
<option value="duplicado">Duplicado</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Severidade">
|
||||
<select v-model="form.severidade">
|
||||
<option value="">—</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Categoria" hint="Ex.: Bug crítico, Dívida técnica, Performance, Arquitetura, Segurança">
|
||||
<input v-model="form.categoria" placeholder="Categoria" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Descrição do problema" hint="O que tá errado, onde acontece, como reproduzir">
|
||||
<textarea v-model="form.descricao_problema" rows="4" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Solução" hint="Como foi/será resolvido">
|
||||
<textarea v-model="form.solucao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Data de resolução">
|
||||
<input v-model="form.resolvido_em" type="date" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Sessão de resolução" hint="Ex.: Sessão 3 — 2026-03-11">
|
||||
<input v-model="form.sessao_resolucao" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Arquivo afetado" hint="src/features/agenda/...">
|
||||
<input v-model="form.arquivo_afetado" placeholder="Caminho do arquivo" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Tags" hint="Separe por vírgula. Ex.: agenda, recorrência, n+1">
|
||||
<input v-model="form.tags" placeholder="tag1, tag2, tag3" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.auditoria { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-left: 3px solid var(--c, var(--primary-color));
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-lbl {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 200px; }
|
||||
.filter-sel { min-width: 160px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.audit-item {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.audit-item:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.audit-item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.audit-item.drop-target { border-color: var(--primary-color); border-style: dashed; background: color-mix(in srgb, var(--primary-color) 5%, transparent); }
|
||||
|
||||
.audit-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.drag-handle:hover { opacity: 1; background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.audit-head > .pi:nth-child(2) { font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.audit-title-wrap { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.audit-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.audit-ref {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
.audit-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.audit-cat {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.audit-file {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.audit-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.audit-chev {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.audit-chev.open { transform: rotate(180deg); }
|
||||
|
||||
.audit-body {
|
||||
padding: 0 14px 14px 42px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.audit-section { margin-bottom: 10px; }
|
||||
.audit-section h5 {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.audit-section p {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.audit-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevDatabaseTab.vue — comandos do db.cjs via copy-to-clipboard
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const toast = useToast();
|
||||
const recentLogs = ref([]);
|
||||
|
||||
const commands = [
|
||||
{
|
||||
group: 'Operação diária',
|
||||
items: [
|
||||
{
|
||||
label: 'Backup completo',
|
||||
cmd: 'cd database-novo && node db.cjs backup',
|
||||
desc: 'Gera schema.sql + data.sql + full_dump.sql + supabase_restore.sql em backups/YYYY-MM-DD/',
|
||||
icon: 'pi-save'
|
||||
},
|
||||
{
|
||||
label: 'Status do banco',
|
||||
cmd: 'cd database-novo && node db.cjs status',
|
||||
desc: 'Mostra estado: container rodando, último backup, migrations aplicadas, contagens',
|
||||
icon: 'pi-chart-bar'
|
||||
},
|
||||
{
|
||||
label: 'Verify (integridade)',
|
||||
cmd: 'cd database-novo && node db.cjs verify',
|
||||
desc: 'Checa tabelas e views essenciais (definidas em db.config.json → verify)',
|
||||
icon: 'pi-check-circle'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Dashboards e exports',
|
||||
items: [
|
||||
{
|
||||
label: 'Gerar dashboard HTML do banco',
|
||||
cmd: 'cd database-novo && node db.cjs dashboard',
|
||||
desc: 'Regenera agenciapsi-db-dashboard.html com domínios + infraestrutura + busca',
|
||||
icon: 'pi-th-large'
|
||||
},
|
||||
{
|
||||
label: 'Schema export (00_full..10_grants)',
|
||||
cmd: 'cd database-novo && node db.cjs schema-export',
|
||||
desc: 'Atualiza as 11 pastas schema/ a partir do estado atual do banco',
|
||||
icon: 'pi-file-export'
|
||||
},
|
||||
{
|
||||
label: 'Diff vs último backup',
|
||||
cmd: 'cd database-novo && node db.cjs diff',
|
||||
desc: 'Lista tabelas novas, alteradas e removidas desde o último backup',
|
||||
icon: 'pi-compare'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Migrations e seeds',
|
||||
items: [
|
||||
{
|
||||
label: 'Aplicar migrations pendentes',
|
||||
cmd: 'cd database-novo && node db.cjs migrate',
|
||||
desc: 'Aplica migrations em database-novo/migrations/ ainda não aplicadas. Faz backup antes.',
|
||||
icon: 'pi-forward'
|
||||
},
|
||||
{
|
||||
label: 'Rodar todos os seeds',
|
||||
cmd: 'cd database-novo && node db.cjs seed all',
|
||||
desc: 'Roda seeds dos grupos users + system',
|
||||
icon: 'pi-database'
|
||||
},
|
||||
{
|
||||
label: 'Seed: system (inclui dev_*)',
|
||||
cmd: 'cd database-novo && node db.cjs seed system',
|
||||
desc: 'Popula tabelas do sistema (planos, features, templates + dev_phases/items/auditoria/competitors)',
|
||||
icon: 'pi-cog'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Operações críticas',
|
||||
items: [
|
||||
{
|
||||
label: 'Restore (último backup)',
|
||||
cmd: 'cd database-novo && node db.cjs restore',
|
||||
desc: 'Restaura do backup mais recente. Prioriza supabase_restore.sql.',
|
||||
icon: 'pi-history',
|
||||
danger: true
|
||||
},
|
||||
{
|
||||
label: 'Reset (DROP + setup)',
|
||||
cmd: 'cd database-novo && node db.cjs reset',
|
||||
desc: 'DROP schema public + reinstala tudo. Faz backup antes.',
|
||||
icon: 'pi-refresh',
|
||||
danger: true
|
||||
},
|
||||
{
|
||||
label: 'Setup do zero',
|
||||
cmd: 'cd database-novo && node db.cjs setup',
|
||||
desc: 'Primeira instalação: schema + fixes + seeds + migrations + backup + verify',
|
||||
icon: 'pi-bolt',
|
||||
danger: true
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('dev_generation_log')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
recentLogs.value = data || [];
|
||||
} catch (e) {
|
||||
logError('DevDatabaseTab', 'loadRecentLogs', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(cmd) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Copiado!',
|
||||
detail: 'Cole no terminal e execute.',
|
||||
life: 2500
|
||||
});
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Falha ao copiar',
|
||||
detail: 'Copie manualmente — clipboard indisponível.',
|
||||
life: 4000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
onMounted(loadLogs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="db-tab">
|
||||
<div class="notice">
|
||||
<i class="pi pi-info-circle" />
|
||||
<div>
|
||||
<strong>Copy-to-clipboard mode.</strong>
|
||||
Os comandos precisam ser executados no terminal (na raiz do projeto). Na Parte C futuro vai ter um CLI server local que executa direto daqui.
|
||||
<br />
|
||||
<span class="text-[var(--text-color-secondary)]">
|
||||
O container Supabase precisa estar rodando (<code>npx supabase start</code>) antes de qualquer comando.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="group in commands" :key="group.group" class="cmd-group">
|
||||
<h3 class="cmd-group-title">{{ group.group }}</h3>
|
||||
<div class="cmd-list">
|
||||
<article v-for="cmd in group.items" :key="cmd.label" :class="['cmd-card', { danger: cmd.danger }]">
|
||||
<div class="cmd-head">
|
||||
<div class="cmd-icon">
|
||||
<i :class="['pi', cmd.icon]" />
|
||||
</div>
|
||||
<div class="cmd-title-wrap">
|
||||
<h4 class="cmd-title">
|
||||
{{ cmd.label }}
|
||||
<span v-if="cmd.danger" class="danger-chip">CUIDADO</span>
|
||||
</h4>
|
||||
<p class="cmd-desc">{{ cmd.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cmd-action">
|
||||
<code class="cmd-text">{{ cmd.cmd }}</code>
|
||||
<button class="cmd-copy" @click="copyToClipboard(cmd.cmd)">
|
||||
<i class="pi pi-copy" /> Copiar
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico -->
|
||||
<div class="cmd-group">
|
||||
<h3 class="cmd-group-title">Últimas execuções registradas</h3>
|
||||
<div v-if="!recentLogs.length" class="empty">Nenhum log ainda.</div>
|
||||
<ul v-else class="log-list">
|
||||
<li v-for="log in recentLogs" :key="log.id" class="log-item">
|
||||
<i
|
||||
:class="['pi', log.sucesso ? 'pi-check-circle' : 'pi-times-circle']"
|
||||
:style="{ color: log.sucesso ? '#10b981' : '#ef4444' }"
|
||||
/>
|
||||
<span class="log-tipo">{{ log.tipo }}</span>
|
||||
<span class="log-cmd">{{ log.comando || '—' }}</span>
|
||||
<span class="log-date">{{ formatDate(log.created_at) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.db-tab { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.notice i { color: var(--primary-color); font-size: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||
.notice strong { color: var(--primary-color); }
|
||||
.notice code { font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
|
||||
|
||||
.cmd-group {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.cmd-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.cmd-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.cmd-card {
|
||||
background: var(--surface-ground, #f8fafc);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cmd-card:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.cmd-card.danger { border-left: 3px solid #ef4444; }
|
||||
|
||||
.cmd-head { display: flex; gap: 10px; margin-bottom: 8px; }
|
||||
.cmd-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmd-card.danger .cmd-icon {
|
||||
background: rgba(239,68,68,.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.cmd-title-wrap { flex: 1; min-width: 0; }
|
||||
|
||||
.cmd-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.danger-chip {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
background: rgba(239,68,68,.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.cmd-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.cmd-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #0b0d12;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.cmd-text {
|
||||
flex: 1;
|
||||
font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
color: #e2e8f8;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.cmd-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cmd-copy:hover { opacity: 0.9; }
|
||||
.cmd-copy:active { transform: scale(0.97); }
|
||||
|
||||
.log-list { list-style: none; margin: 0; padding: 0; }
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.log-item:last-child { border-bottom: none; }
|
||||
.log-tipo {
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.log-cmd {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-date { font-size: 11px; color: var(--text-color-secondary); }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevEstruturaTab.vue — ESTRUTURA.md + mapa-sistema.html embarcados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import estruturaRaw from '@/../development/01-visao-geral/ESTRUTURA.md?raw';
|
||||
import mapaSistemaHtml from '@/../development/01-visao-geral/mapa-sistema.html?raw';
|
||||
|
||||
const viewMode = ref('tree'); // 'tree' | 'mapa'
|
||||
const content = ref(estruturaRaw);
|
||||
|
||||
// Gerar blob URL do mapa-sistema.html pra embedar no iframe
|
||||
const mapaUrl = computed(() => {
|
||||
const blob = new Blob([mapaSistemaHtml], { type: 'text/html' });
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="estrutura">
|
||||
<div class="view-toggle">
|
||||
<button :class="['toggle-btn', { active: viewMode === 'tree' }]" @click="viewMode = 'tree'">
|
||||
<i class="pi pi-list" /> Árvore (ESTRUTURA.md)
|
||||
</button>
|
||||
<button :class="['toggle-btn', { active: viewMode === 'mapa' }]" @click="viewMode = 'mapa'">
|
||||
<i class="pi pi-sitemap" /> Mapa interativo
|
||||
</button>
|
||||
<a
|
||||
href="/development/01-visao-geral/mapa-sistema.html"
|
||||
target="_blank"
|
||||
class="external-link"
|
||||
title="Abrir em nova aba"
|
||||
>
|
||||
<i class="pi pi-external-link" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="viewMode === 'tree'" class="md-container">
|
||||
<pre class="md-content">{{ content }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-else class="mapa-container">
|
||||
<iframe :src="mapaUrl" class="mapa-frame" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.estrutura {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.external-link {
|
||||
margin-left: auto;
|
||||
padding: 7px 10px;
|
||||
color: var(--text-color-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.external-link:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.md-container {
|
||||
background: #0b0d12;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-content {
|
||||
margin: 0;
|
||||
padding: 20px 24px;
|
||||
color: #e2e8f8;
|
||||
font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-x: auto;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mapa-container {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
min-height: 800px;
|
||||
height: 85vh;
|
||||
}
|
||||
|
||||
.mapa-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevExportTab.vue — Exportar banco → .md (stub read-only por ora)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
function notImplemented() {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Pendente — Parte C',
|
||||
detail: 'Geração de .md a partir do banco será implementada na próxima etapa.',
|
||||
life: 4000
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="export-tab">
|
||||
<div class="notice">
|
||||
<i class="pi pi-info-circle" />
|
||||
<div>
|
||||
<strong>Área de exportação — pendente na Parte C</strong>
|
||||
<p>
|
||||
Nesta aba você vai poder regenerar os arquivos <code>.md</code> em <code>development/</code> a partir dos dados do banco (banco vira source-of-truth, arquivo vira artefato versionável).
|
||||
</p>
|
||||
<p class="text-[var(--text-color-secondary)]" style="margin-top: 6px">
|
||||
Detalhes do que falta estão em <code>development/PENDENTE.md</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-grid">
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-flag" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>ROADMAP.md</h3>
|
||||
<p>Regenera o arquivo roadmap a partir de <code>dev_roadmap_phases</code> + <code>dev_roadmap_items</code>.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-verified" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>AUDITORIA.md</h3>
|
||||
<p>Regenera a auditoria técnica a partir de <code>dev_auditoria_items</code>.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-globe" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>concorrentes.md</h3>
|
||||
<p>Regenera o benchmark a partir de <code>dev_competitors</code> + <code>dev_competitor_features</code> + <code>dev_comparison_matrix</code>.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="export-card">
|
||||
<div class="export-icon">
|
||||
<i class="pi pi-sitemap" />
|
||||
</div>
|
||||
<div class="export-body">
|
||||
<h3>ESTRUTURA.md</h3>
|
||||
<p>Regenera o snapshot da estrutura — hoje é estático, futuro será derivado do menu + banco.</p>
|
||||
</div>
|
||||
<button class="export-btn" disabled @click="notImplemented">
|
||||
<i class="pi pi-download" /> Gerar (Parte C)
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-tab { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
background: color-mix(in srgb, #f59e0b 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.notice i { color: #f59e0b; font-size: 18px; flex-shrink: 0; margin-top: 1px; }
|
||||
.notice strong { color: #d97706; display: block; margin-bottom: 6px; }
|
||||
.notice p { line-height: 1.5; margin: 0; }
|
||||
.notice code { font-family: 'IBM Plex Mono', monospace; font-size: 11px; background: rgba(0,0,0,.05); padding: 1px 5px; border-radius: 3px; }
|
||||
|
||||
.export-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.export-card {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.export-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.export-body h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0 0 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.export-body p {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.export-body code {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.export-btn:disabled {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevOverviewTab.vue — Visão Geral da área de desenvolvimento
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
const loading = ref(true);
|
||||
const stats = ref({
|
||||
phases: [],
|
||||
roadmapTotal: 0,
|
||||
roadmapConcluido: 0,
|
||||
roadmapEmAndamento: 0,
|
||||
roadmapPendente: 0,
|
||||
auditoriaAberto: 0,
|
||||
auditoriaResolvido: 0,
|
||||
auditoriaTotal: 0,
|
||||
competitors: 0,
|
||||
competitorFeatures: 0,
|
||||
comparisonGaps: 0,
|
||||
comparisonTem: 0,
|
||||
comparisonParcial: 0,
|
||||
lastGenerations: []
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [phasesRes, itemsRes, auditoriaRes, competitorsRes, featuresRes, matrixRes, logRes] = await Promise.all([
|
||||
supabase.from('dev_roadmap_phases').select('*').order('ordem'),
|
||||
supabase.from('dev_roadmap_items').select('id, phase_id, status'),
|
||||
supabase.from('dev_auditoria_items').select('id, status'),
|
||||
supabase.from('dev_competitors').select('id, nome, pais', { count: 'exact' }),
|
||||
supabase.from('dev_competitor_features').select('id', { count: 'exact', head: true }),
|
||||
supabase.from('dev_comparison_matrix').select('id, nosso_status'),
|
||||
supabase.from('dev_generation_log').select('*').order('created_at', { ascending: false }).limit(5)
|
||||
]);
|
||||
|
||||
// Phases with item counts per status
|
||||
const items = itemsRes.data || [];
|
||||
stats.value.phases = (phasesRes.data || []).map((p) => {
|
||||
const phaseItems = items.filter((i) => i.phase_id === p.id);
|
||||
return {
|
||||
...p,
|
||||
total: phaseItems.length,
|
||||
concluido: phaseItems.filter((i) => i.status === 'concluido').length,
|
||||
em_andamento: phaseItems.filter((i) => i.status === 'em_andamento').length,
|
||||
pendente: phaseItems.filter((i) => i.status === 'pendente').length
|
||||
};
|
||||
});
|
||||
|
||||
stats.value.roadmapTotal = items.length;
|
||||
stats.value.roadmapConcluido = items.filter((i) => i.status === 'concluido').length;
|
||||
stats.value.roadmapEmAndamento = items.filter((i) => i.status === 'em_andamento').length;
|
||||
stats.value.roadmapPendente = items.filter((i) => i.status === 'pendente').length;
|
||||
|
||||
const aud = auditoriaRes.data || [];
|
||||
stats.value.auditoriaTotal = aud.length;
|
||||
stats.value.auditoriaAberto = aud.filter((i) => ['aberto', 'em_analise'].includes(i.status)).length;
|
||||
stats.value.auditoriaResolvido = aud.filter((i) => i.status === 'resolvido').length;
|
||||
|
||||
stats.value.competitors = competitorsRes.count || (competitorsRes.data || []).length;
|
||||
stats.value.competitorFeatures = featuresRes.count || 0;
|
||||
|
||||
const matrix = matrixRes.data || [];
|
||||
stats.value.comparisonGaps = matrix.filter((m) => m.nosso_status === 'gap').length;
|
||||
stats.value.comparisonTem = matrix.filter((m) => m.nosso_status === 'tem').length;
|
||||
stats.value.comparisonParcial = matrix.filter((m) => m.nosso_status === 'parcial').length;
|
||||
|
||||
stats.value.lastGenerations = logRes.data || [];
|
||||
} catch (e) {
|
||||
logError('DevOverviewTab', 'loadStats', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function percent(done, total) {
|
||||
if (!total) return 0;
|
||||
return Math.round((done / total) * 100);
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
return {
|
||||
planejada: 'var(--text-color-secondary)',
|
||||
em_andamento: '#f59e0b',
|
||||
concluida: '#10b981',
|
||||
arquivada: '#94a3b8'
|
||||
}[status] || 'var(--text-color-secondary)';
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
onMounted(loadStats);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mt-2">Carregando estatísticas...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overview">
|
||||
<!-- Cards de stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #6366f1 12%, transparent); color: #6366f1">
|
||||
<i class="pi pi-flag" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.roadmapTotal }}</div>
|
||||
<div class="stat-label">features no roadmap</div>
|
||||
<div class="stat-sub">
|
||||
<span class="done">{{ stats.roadmapConcluido }} concluídas</span> ·
|
||||
<span class="doing">{{ stats.roadmapEmAndamento }} em andamento</span> ·
|
||||
<span class="pending">{{ stats.roadmapPendente }} pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #ef4444 12%, transparent); color: #ef4444">
|
||||
<i class="pi pi-verified" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.auditoriaAberto }}</div>
|
||||
<div class="stat-label">bugs/débitos abertos</div>
|
||||
<div class="stat-sub">
|
||||
<span>{{ stats.auditoriaResolvido }} resolvidos</span> ·
|
||||
<span>{{ stats.auditoriaTotal }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #0ea5e9 12%, transparent); color: #0ea5e9">
|
||||
<i class="pi pi-globe" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.competitors }}</div>
|
||||
<div class="stat-label">concorrentes analisados</div>
|
||||
<div class="stat-sub">{{ stats.competitorFeatures }} features catalogadas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: color-mix(in srgb, #f59e0b 12%, transparent); color: #f59e0b">
|
||||
<i class="pi pi-exclamation-triangle" />
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.comparisonGaps }}</div>
|
||||
<div class="stat-label">gaps vs mercado</div>
|
||||
<div class="stat-sub">
|
||||
{{ stats.comparisonTem }} temos · {{ stats.comparisonParcial }} parciais
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progresso das fases -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Progresso das Fases</h2>
|
||||
<div class="phases-grid">
|
||||
<article v-for="phase in stats.phases" :key="phase.id" class="phase-card">
|
||||
<header class="phase-head">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="phase-num">{{ phase.numero }}</span>
|
||||
<h3 class="phase-name">{{ phase.nome }}</h3>
|
||||
</div>
|
||||
<span class="phase-status" :style="{ color: statusColor(phase.status) }">
|
||||
{{ phase.status.replace('_', ' ') }}
|
||||
</span>
|
||||
</header>
|
||||
<p class="phase-obj">{{ phase.objetivo }}</p>
|
||||
<div class="phase-progress">
|
||||
<div class="phase-bar">
|
||||
<div class="phase-fill" :style="{ width: percent(phase.concluido, phase.total) + '%' }" />
|
||||
</div>
|
||||
<span class="phase-perc">{{ percent(phase.concluido, phase.total) }}%</span>
|
||||
</div>
|
||||
<div class="phase-counts">
|
||||
<span class="pc-done">{{ phase.concluido }} concluídos</span>
|
||||
<span class="pc-doing">{{ phase.em_andamento }} em andamento</span>
|
||||
<span class="pc-pending">{{ phase.pendente }} pendentes</span>
|
||||
<span class="pc-total">{{ phase.total }} total</span>
|
||||
</div>
|
||||
<div class="phase-timeline">
|
||||
<i class="pi pi-clock text-xs" />
|
||||
<span>{{ phase.timeline_sugerida || '—' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Últimas gerações -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Últimas gerações (backup, dashboard, seed)</h2>
|
||||
<div v-if="stats.lastGenerations.length === 0" class="empty">Nenhuma geração registrada ainda.</div>
|
||||
<ul v-else class="gen-list">
|
||||
<li v-for="g in stats.lastGenerations" :key="g.id" class="gen-item">
|
||||
<i
|
||||
:class="['pi', g.sucesso ? 'pi-check-circle' : 'pi-times-circle']"
|
||||
:style="{ color: g.sucesso ? '#10b981' : '#ef4444' }"
|
||||
/>
|
||||
<span class="gen-tipo">{{ g.tipo }}</span>
|
||||
<span class="gen-cmd">{{ g.comando || '—' }}</span>
|
||||
<span class="gen-date">{{ formatDate(g.created_at) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.stat-sub .done { color: #10b981; }
|
||||
.stat-sub .doing { color: #f59e0b; }
|
||||
.stat-sub .pending { color: var(--text-color-secondary); }
|
||||
|
||||
.section {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.phases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phase-card {
|
||||
background: var(--surface-ground, #f8fafc);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.phase-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.phase-num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.phase-status {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phase-obj {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.phase-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.phase-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--surface-border, #e2e8f0);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #10b981, #6366f1);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.phase-perc {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.phase-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pc-done { color: #10b981; }
|
||||
.pc-doing { color: #f59e0b; }
|
||||
.pc-pending { color: var(--text-color-secondary); }
|
||||
.pc-total { color: var(--text-color-secondary); margin-left: auto; }
|
||||
|
||||
.phase-timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gen-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gen-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.gen-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gen-tipo {
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gen-cmd {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gen-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,794 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevRoadmapTab.vue — Fases + Items (CRUD + drag-drop + editar fase)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import BlocoItems from './components/BlocoItems.vue';
|
||||
import { reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const phases = ref([]);
|
||||
const items = ref([]);
|
||||
const activePhase = ref(null);
|
||||
const filterStatus = ref('all');
|
||||
const filterPrior = ref('all');
|
||||
const search = ref('');
|
||||
|
||||
// Drawer item
|
||||
const itemDrawerOpen = ref(false);
|
||||
const itemSaving = ref(false);
|
||||
const itemEditingId = ref(null);
|
||||
const itemForm = ref(emptyItemForm());
|
||||
|
||||
// Drawer phase
|
||||
const phaseDrawerOpen = ref(false);
|
||||
const phaseSaving = ref(false);
|
||||
const phaseEditingId = ref(null);
|
||||
const phaseForm = ref(emptyPhaseForm());
|
||||
|
||||
function emptyItemForm() {
|
||||
return {
|
||||
phase_id: null,
|
||||
numero: null,
|
||||
bloco: '',
|
||||
feature: '',
|
||||
descricao: '',
|
||||
esforco: 'M',
|
||||
prioridade: 'alta',
|
||||
status: 'pendente',
|
||||
notas: '',
|
||||
assignee: '',
|
||||
data_inicio: null,
|
||||
data_conclusao: null
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPhaseForm() {
|
||||
return {
|
||||
numero: null,
|
||||
nome: '',
|
||||
objetivo: '',
|
||||
timeline_sugerida: '',
|
||||
criterio_saida: '',
|
||||
status: 'planejada',
|
||||
data_inicio: null,
|
||||
data_fim: null
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pendente: { label: 'Pendente', color: 'var(--text-color-secondary)', bg: 'var(--surface-border)' },
|
||||
em_andamento: { label: 'Em andamento', color: '#f59e0b', bg: 'rgba(245,158,11,.15)' },
|
||||
concluido: { label: 'Concluído', color: '#10b981', bg: 'rgba(16,185,129,.15)' },
|
||||
cancelado: { label: 'Cancelado', color: '#94a3b8', bg: 'rgba(148,163,184,.15)' },
|
||||
bloqueado: { label: 'Bloqueado', color: '#ef4444', bg: 'rgba(239,68,68,.15)' }
|
||||
};
|
||||
|
||||
const PRIOR_LABEL = {
|
||||
bloqueador: { label: 'Bloqueador', color: '#ef4444' },
|
||||
alta: { label: 'Alta', color: '#f59e0b' },
|
||||
media: { label: 'Média', color: '#0ea5e9' },
|
||||
diferencial: { label: 'Diferencial', color: '#a855f7' }
|
||||
};
|
||||
|
||||
const ESFORCO_LABEL = {
|
||||
S: { label: 'S', desc: '<1 semana', color: '#10b981' },
|
||||
M: { label: 'M', desc: '1-3 semanas', color: '#0ea5e9' },
|
||||
L: { label: 'L', desc: '3-8 semanas', color: '#f59e0b' },
|
||||
XL: { label: 'XL', desc: '2+ meses', color: '#ef4444' }
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [pRes, iRes] = await Promise.all([
|
||||
supabase.from('dev_roadmap_phases').select('*').order('ordem'),
|
||||
supabase.from('dev_roadmap_items').select('*').order('phase_id').order('ordem')
|
||||
]);
|
||||
if (pRes.error) throw pRes.error;
|
||||
if (iRes.error) throw iRes.error;
|
||||
phases.value = pRes.data || [];
|
||||
items.value = iRes.data || [];
|
||||
if (phases.value.length && activePhase.value === null) {
|
||||
activePhase.value = phases.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const activePhaseObj = computed(() => phases.value.find((p) => p.id === activePhase.value));
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value.filter((i) => i.phase_id === activePhase.value);
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterPrior.value !== 'all') list = list.filter((i) => i.prioridade === filterPrior.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.feature.toLowerCase().includes(q) ||
|
||||
(i.descricao || '').toLowerCase().includes(q) ||
|
||||
(i.bloco || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const itemsByBloco = computed(() => {
|
||||
const groups = {};
|
||||
for (const item of itemsFiltered.value) {
|
||||
const key = item.bloco || 'Sem categoria';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
function countByStatus(phaseId, status) {
|
||||
return items.value.filter((i) => i.phase_id === phaseId && i.status === status).length;
|
||||
}
|
||||
|
||||
// ── CRUD item ────────────────────────────────────────────────────
|
||||
function openNewItem() {
|
||||
itemEditingId.value = null;
|
||||
itemForm.value = emptyItemForm();
|
||||
itemForm.value.phase_id = activePhase.value;
|
||||
itemDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditItem(item) {
|
||||
itemEditingId.value = item.id;
|
||||
itemForm.value = {
|
||||
phase_id: item.phase_id,
|
||||
numero: item.numero,
|
||||
bloco: item.bloco || '',
|
||||
feature: item.feature,
|
||||
descricao: item.descricao || '',
|
||||
esforco: item.esforco || 'M',
|
||||
prioridade: item.prioridade || 'alta',
|
||||
status: item.status,
|
||||
notas: item.notas || '',
|
||||
assignee: item.assignee || '',
|
||||
data_inicio: item.data_inicio || null,
|
||||
data_conclusao: item.data_conclusao || null
|
||||
};
|
||||
itemDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (!itemForm.value.feature.trim() || !itemForm.value.phase_id) return;
|
||||
itemSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
phase_id: itemForm.value.phase_id,
|
||||
numero: itemForm.value.numero ? Number(itemForm.value.numero) : null,
|
||||
bloco: itemForm.value.bloco.trim() || null,
|
||||
feature: itemForm.value.feature.trim(),
|
||||
descricao: itemForm.value.descricao.trim() || null,
|
||||
esforco: itemForm.value.esforco || null,
|
||||
prioridade: itemForm.value.prioridade || null,
|
||||
status: itemForm.value.status,
|
||||
notas: itemForm.value.notas.trim() || null,
|
||||
assignee: itemForm.value.assignee.trim() || null,
|
||||
data_inicio: itemForm.value.data_inicio || null,
|
||||
data_conclusao: itemForm.value.data_conclusao || null
|
||||
};
|
||||
if (itemEditingId.value) {
|
||||
const { error } = await supabase.from('dev_roadmap_items').update(payload).eq('id', itemEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
const maxOrdem = Math.max(
|
||||
0,
|
||||
...items.value.filter((i) => i.phase_id === payload.phase_id).map((i) => i.ordem || 0)
|
||||
);
|
||||
payload.ordem = maxOrdem + 1;
|
||||
const { error } = await supabase.from('dev_roadmap_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
itemDrawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
itemSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDeleteItem() {
|
||||
if (!itemEditingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Excluir este item do roadmap?',
|
||||
header: 'Confirmar',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_roadmap_items').delete().eq('id', itemEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
itemDrawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── CRUD phase ───────────────────────────────────────────────────
|
||||
function openNewPhase() {
|
||||
phaseEditingId.value = null;
|
||||
const maxNum = Math.max(0, ...phases.value.map((p) => p.numero || 0));
|
||||
phaseForm.value = emptyPhaseForm();
|
||||
phaseForm.value.numero = maxNum + 1;
|
||||
phaseDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditPhase() {
|
||||
if (!activePhaseObj.value) return;
|
||||
const p = activePhaseObj.value;
|
||||
phaseEditingId.value = p.id;
|
||||
phaseForm.value = {
|
||||
numero: p.numero,
|
||||
nome: p.nome,
|
||||
objetivo: p.objetivo || '',
|
||||
timeline_sugerida: p.timeline_sugerida || '',
|
||||
criterio_saida: p.criterio_saida || '',
|
||||
status: p.status,
|
||||
data_inicio: p.data_inicio || null,
|
||||
data_fim: p.data_fim || null
|
||||
};
|
||||
phaseDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function savePhase() {
|
||||
if (!phaseForm.value.nome.trim() || !phaseForm.value.numero) return;
|
||||
phaseSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
numero: Number(phaseForm.value.numero),
|
||||
nome: phaseForm.value.nome.trim(),
|
||||
objetivo: phaseForm.value.objetivo.trim() || null,
|
||||
timeline_sugerida: phaseForm.value.timeline_sugerida.trim() || null,
|
||||
criterio_saida: phaseForm.value.criterio_saida.trim() || null,
|
||||
status: phaseForm.value.status,
|
||||
data_inicio: phaseForm.value.data_inicio || null,
|
||||
data_fim: phaseForm.value.data_fim || null
|
||||
};
|
||||
if (phaseEditingId.value) {
|
||||
const { error } = await supabase.from('dev_roadmap_phases').update(payload).eq('id', phaseEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (phases.value.length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_roadmap_phases').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
phaseDrawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
phaseSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDeletePhase() {
|
||||
if (!phaseEditingId.value) return;
|
||||
const phaseItemCount = items.value.filter((i) => i.phase_id === phaseEditingId.value).length;
|
||||
confirm.require({
|
||||
message: `Excluir esta fase? ${phaseItemCount > 0 ? `Ela tem ${phaseItemCount} item(s), que serão excluídos junto.` : ''}`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_roadmap_phases').delete().eq('id', phaseEditingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Fase excluída', life: 2000 });
|
||||
phaseDrawerOpen.value = false;
|
||||
activePhase.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorderBloco(blocoItems, fromIdx, toIdx) {
|
||||
const hasFilter = filterStatus.value !== 'all' || filterPrior.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Reordena items dentro do bloco + atualiza ordem global
|
||||
const reordered = reorderWithIndexes(blocoItems, fromIdx, toIdx);
|
||||
try {
|
||||
// Pega a ordem base do primeiro item do bloco e incrementa
|
||||
const base = Math.min(...blocoItems.map((i) => i.ordem || 0));
|
||||
const updates = reordered.map((item, idx) =>
|
||||
supabase.from('dev_roadmap_items').update({ ordem: base + idx }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="roadmap">
|
||||
<!-- Seletor de fase -->
|
||||
<div class="phase-selector">
|
||||
<button
|
||||
v-for="phase in phases"
|
||||
:key="phase.id"
|
||||
:class="['phase-btn', { active: phase.id === activePhase }]"
|
||||
@click="activePhase = phase.id"
|
||||
>
|
||||
<span class="phase-btn-num">{{ phase.numero }}</span>
|
||||
<div class="phase-btn-info">
|
||||
<strong>{{ phase.nome }}</strong>
|
||||
<small>{{ phase.timeline_sugerida }}</small>
|
||||
</div>
|
||||
<div class="phase-btn-counts">
|
||||
<span class="done">{{ countByStatus(phase.id, 'concluido') }}</span>
|
||||
<span class="total">/ {{ items.filter((i) => i.phase_id === phase.id).length }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="phase-btn phase-add" @click="openNewPhase" title="Nova fase">
|
||||
<i class="pi pi-plus" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Header da fase ativa -->
|
||||
<div v-if="activePhaseObj" class="phase-header">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="phase-title">
|
||||
<span class="phase-chip">Fase {{ activePhaseObj.numero }}</span>
|
||||
{{ activePhaseObj.nome }}
|
||||
</h2>
|
||||
<p class="phase-desc">{{ activePhaseObj.objetivo }}</p>
|
||||
<div class="phase-meta">
|
||||
<span v-if="activePhaseObj.timeline_sugerida"><i class="pi pi-clock text-xs" /> {{ activePhaseObj.timeline_sugerida }}</span>
|
||||
<span v-if="activePhaseObj.criterio_saida">
|
||||
<i class="pi pi-check-circle text-xs" /> {{ activePhaseObj.criterio_saida }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon" @click="openEditPhase" title="Editar fase">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar feature..." class="filter-search" />
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="concluido">Concluído</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
<option value="bloqueado">Bloqueado</option>
|
||||
</select>
|
||||
<select v-model="filterPrior" class="filter-sel">
|
||||
<option value="all">Todas as prioridades</option>
|
||||
<option value="bloqueador">Bloqueador</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="media">Média</option>
|
||||
<option value="diferencial">Diferencial</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNewItem">
|
||||
<i class="pi pi-plus" /> Novo item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Itens agrupados por bloco -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item encontrado. Use "Novo item" pra adicionar.
|
||||
</div>
|
||||
|
||||
<div v-for="(blocoItems, bloco) in itemsByBloco" :key="bloco" class="bloco">
|
||||
<h3 class="bloco-title">
|
||||
<i class="pi pi-folder-open" />
|
||||
{{ bloco }}
|
||||
<span class="bloco-count">{{ blocoItems.length }}</span>
|
||||
</h3>
|
||||
<BlocoItems :items="blocoItems" :reorder-fn="handleReorderBloco" :on-edit="openEditItem" />
|
||||
</div>
|
||||
|
||||
<!-- Drawer: Item -->
|
||||
<DevDrawer
|
||||
:open="itemDrawerOpen"
|
||||
:title="itemEditingId ? 'Editar item' : 'Novo item de roadmap'"
|
||||
:subtitle="itemEditingId ? `#${itemEditingId}` : ''"
|
||||
:can-save="!!itemForm.feature.trim() && !!itemForm.phase_id"
|
||||
:saving="itemSaving"
|
||||
:danger="!!itemEditingId"
|
||||
@close="itemDrawerOpen = false"
|
||||
@save="saveItem"
|
||||
@delete="askDeleteItem"
|
||||
>
|
||||
<DevField label="Fase" required>
|
||||
<select v-model="itemForm.phase_id">
|
||||
<option v-for="p in phases" :key="p.id" :value="p.id">
|
||||
Fase {{ p.numero }} — {{ p.nome }}
|
||||
</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Bloco" hint="Ex.: Monetização, Compliance BR, IA">
|
||||
<input v-model="itemForm.bloco" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Nº" hint="Opcional (pra referência)">
|
||||
<input v-model="itemForm.numero" type="number" min="1" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Feature" required>
|
||||
<input v-model="itemForm.feature" placeholder="Ex.: Integração com gateway de pagamento" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Descrição">
|
||||
<textarea v-model="itemForm.descricao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row three">
|
||||
<DevField label="Esforço">
|
||||
<select v-model="itemForm.esforco">
|
||||
<option value="">—</option>
|
||||
<option value="S">S (<1 sem)</option>
|
||||
<option value="M">M (1-3 sem)</option>
|
||||
<option value="L">L (3-8 sem)</option>
|
||||
<option value="XL">XL (2+ meses)</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Prioridade">
|
||||
<select v-model="itemForm.prioridade">
|
||||
<option value="">—</option>
|
||||
<option value="bloqueador">Bloqueador</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="media">Média</option>
|
||||
<option value="diferencial">Diferencial</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="itemForm.status">
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="concluido">Concluído</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
<option value="bloqueado">Bloqueado</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Notas/Observações" hint="Contexto extra, decisões, caveats">
|
||||
<textarea v-model="itemForm.notas" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Início">
|
||||
<input v-model="itemForm.data_inicio" type="date" />
|
||||
</DevField>
|
||||
<DevField label="Conclusão">
|
||||
<input v-model="itemForm.data_conclusao" type="date" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Assignee">
|
||||
<input v-model="itemForm.assignee" placeholder="Nome/email" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
|
||||
<!-- Drawer: Phase -->
|
||||
<DevDrawer
|
||||
:open="phaseDrawerOpen"
|
||||
:title="phaseEditingId ? 'Editar fase' : 'Nova fase'"
|
||||
:can-save="!!phaseForm.nome.trim() && !!phaseForm.numero"
|
||||
:saving="phaseSaving"
|
||||
:danger="!!phaseEditingId"
|
||||
@close="phaseDrawerOpen = false"
|
||||
@save="savePhase"
|
||||
@delete="askDeletePhase"
|
||||
>
|
||||
<div class="form-row">
|
||||
<DevField label="Número" required>
|
||||
<input v-model="phaseForm.numero" type="number" min="1" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="phaseForm.status">
|
||||
<option value="planejada">Planejada</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="concluida">Concluída</option>
|
||||
<option value="arquivada">Arquivada</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Nome da fase" required>
|
||||
<input v-model="phaseForm.nome" placeholder="Ex.: MVP Launch" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Objetivo">
|
||||
<textarea v-model="phaseForm.objetivo" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Timeline sugerida">
|
||||
<input v-model="phaseForm.timeline_sugerida" placeholder="Ex.: 4-6 semanas" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Critério de saída">
|
||||
<textarea v-model="phaseForm.criterio_saida" rows="2" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Data de início">
|
||||
<input v-model="phaseForm.data_inicio" type="date" />
|
||||
</DevField>
|
||||
<DevField label="Data de fim">
|
||||
<input v-model="phaseForm.data_fim" type="date" />
|
||||
</DevField>
|
||||
</div>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.roadmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.phase-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.phase-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1.5px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.phase-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
.phase-btn.active {
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
||||
}
|
||||
|
||||
.phase-btn-num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.phase-btn-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.phase-btn-info strong { font-size: 13px; font-weight: 600; }
|
||||
.phase-btn-info small { font-size: 11px; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.phase-btn-counts {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.phase-btn-counts .done { color: #10b981; }
|
||||
.phase-btn-counts .total { color: var(--text-color-secondary); font-size: 11px; }
|
||||
|
||||
.phase-add {
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 16px;
|
||||
min-height: 64px;
|
||||
}
|
||||
.phase-add:hover { color: var(--primary-color); }
|
||||
|
||||
.phase-header {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.phase-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.phase-chip {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.phase-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.phase-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.phase-meta span { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 200px; }
|
||||
.filter-sel { min-width: 160px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bloco {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.bloco-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.bloco-title i { color: var(--primary-color); }
|
||||
.bloco-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
background: var(--surface-border);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row.three {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,731 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevTestsTab.vue — Catálogo de suítes de teste (CRUD + drag-drop)
|
||||
|--------------------------------------------------------------------------
|
||||
| Responde "o que está testado?" sem rodar npm test. Cada linha é uma
|
||||
| suíte (arquivo .spec.js, grupo de testes SQL ou plano manual).
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import { useDraggableList, reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const items = ref([]);
|
||||
const filterArea = ref('all');
|
||||
const filterStatus = ref('all');
|
||||
const filterCategoria = ref('all');
|
||||
const search = ref('');
|
||||
const openRow = ref(null);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
area: '',
|
||||
categoria: 'unit',
|
||||
titulo: '',
|
||||
arquivo: '',
|
||||
descricao: '',
|
||||
total_tests: 0,
|
||||
passing: 0,
|
||||
failing: 0,
|
||||
skipped: 0,
|
||||
cobertura_pct: null,
|
||||
status: 'ok',
|
||||
last_run_at: null,
|
||||
sessao_criacao: '',
|
||||
notas: '',
|
||||
tags: ''
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
ok: { label: 'OK', color: '#10b981', bg: 'rgba(16,185,129,.12)', icon: 'pi-check-circle' },
|
||||
falhando: { label: 'Falhando', color: '#ef4444', bg: 'rgba(239,68,68,.12)', icon: 'pi-exclamation-triangle' },
|
||||
pendente: { label: 'Pendente', color: '#0ea5e9', bg: 'rgba(14,165,233,.12)', icon: 'pi-hourglass' },
|
||||
obsoleto: { label: 'Obsoleto', color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-ban' },
|
||||
a_escrever: { label: 'A escrever', color: '#f59e0b', bg: 'rgba(245,158,11,.12)', icon: 'pi-pencil' }
|
||||
};
|
||||
|
||||
const CATEGORIA_OPTIONS = ['unit', 'integration', 'e2e', 'manual', 'a_escrever'];
|
||||
|
||||
const AREA_SUGGESTIONS = [
|
||||
'agenda', 'auth', 'router', 'session', 'stores', 'utils',
|
||||
'pacientes', 'financeiro', 'documentos', 'comunicacao',
|
||||
'saas-admin', 'database', 'rls', 'e2e'
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('dev_test_items')
|
||||
.select('*')
|
||||
.order('area')
|
||||
.order('ordem')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
items.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const areasList = computed(() => {
|
||||
const set = new Set();
|
||||
items.value.forEach((i) => i.area && set.add(i.area));
|
||||
AREA_SUGGESTIONS.forEach((a) => set.add(a));
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value;
|
||||
if (filterArea.value !== 'all') list = list.filter((i) => i.area === filterArea.value);
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterCategoria.value !== 'all') list = list.filter((i) => i.categoria === filterCategoria.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.titulo.toLowerCase().includes(q) ||
|
||||
(i.descricao || '').toLowerCase().includes(q) ||
|
||||
(i.arquivo || '').toLowerCase().includes(q) ||
|
||||
(i.area || '').toLowerCase().includes(q) ||
|
||||
`t#${i.id}`.includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const counts = computed(() => {
|
||||
const total = items.value.reduce((acc, i) => acc + (i.total_tests || 0), 0);
|
||||
const passing = items.value.reduce((acc, i) => acc + (i.passing || 0), 0);
|
||||
const failing = items.value.reduce((acc, i) => acc + (i.failing || 0), 0);
|
||||
return {
|
||||
suites: items.value.length,
|
||||
ok: items.value.filter((i) => i.status === 'ok').length,
|
||||
falhando: items.value.filter((i) => i.status === 'falhando').length,
|
||||
a_escrever: items.value.filter((i) => i.status === 'a_escrever').length,
|
||||
total,
|
||||
passing,
|
||||
failing
|
||||
};
|
||||
});
|
||||
|
||||
function toggle(id) {
|
||||
openRow.value = openRow.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return `${d.toLocaleDateString('pt-BR')} ${d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
if (filterArea.value !== 'all') form.value.area = filterArea.value;
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editingId.value = item.id;
|
||||
form.value = {
|
||||
area: item.area || '',
|
||||
categoria: item.categoria || 'unit',
|
||||
titulo: item.titulo || '',
|
||||
arquivo: item.arquivo || '',
|
||||
descricao: item.descricao || '',
|
||||
total_tests: item.total_tests ?? 0,
|
||||
passing: item.passing ?? 0,
|
||||
failing: item.failing ?? 0,
|
||||
skipped: item.skipped ?? 0,
|
||||
cobertura_pct: item.cobertura_pct,
|
||||
status: item.status || 'ok',
|
||||
last_run_at: item.last_run_at ? item.last_run_at.substring(0, 16) : null,
|
||||
sessao_criacao: item.sessao_criacao || '',
|
||||
notas: item.notas || '',
|
||||
tags: (item.tags || []).join(', ')
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.titulo.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Título obrigatório', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (!form.value.area.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Área obrigatória', life: 3000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
area: form.value.area.trim(),
|
||||
categoria: form.value.categoria.trim() || null,
|
||||
titulo: form.value.titulo.trim(),
|
||||
arquivo: form.value.arquivo.trim() || null,
|
||||
descricao: form.value.descricao.trim() || null,
|
||||
total_tests: Number(form.value.total_tests) || 0,
|
||||
passing: Number(form.value.passing) || 0,
|
||||
failing: Number(form.value.failing) || 0,
|
||||
skipped: Number(form.value.skipped) || 0,
|
||||
cobertura_pct: form.value.cobertura_pct !== '' && form.value.cobertura_pct !== null ? Number(form.value.cobertura_pct) : null,
|
||||
status: form.value.status,
|
||||
last_run_at: form.value.last_run_at || null,
|
||||
sessao_criacao: form.value.sessao_criacao.trim() || null,
|
||||
notas: form.value.notas.trim() || null,
|
||||
tags: form.value.tags
|
||||
? form.value.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: []
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
const { error } = await supabase.from('dev_test_items').update(payload).eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (items.value.filter((i) => i.area === payload.area).length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_test_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
if (!editingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Tem certeza que quer excluir este item?',
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_test_items').delete().eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorder(fromIdx, toIdx) {
|
||||
const hasFilter = filterArea.value !== 'all' || filterStatus.value !== 'all' || filterCategoria.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
detail: 'Drag-drop só funciona com a lista completa.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
const before = items.value;
|
||||
items.value = reorderWithIndexes(before, fromIdx, toIdx);
|
||||
try {
|
||||
const updates = items.value.map((item) =>
|
||||
supabase.from('dev_test_items').update({ ordem: item.ordem }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
} catch (e) {
|
||||
items.value = before;
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
const drag = useDraggableList({ onReorder: handleReorder });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="testes">
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">{{ counts.suites }}</span>
|
||||
<span class="stat-lbl">suítes</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #10b981">
|
||||
<span class="stat-num">{{ counts.passing }}<small class="stat-sub">/{{ counts.total }}</small></span>
|
||||
<span class="stat-lbl">passing</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #ef4444">
|
||||
<span class="stat-num">{{ counts.failing }}</span>
|
||||
<span class="stat-lbl">falhando</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #f59e0b">
|
||||
<span class="stat-num">{{ counts.a_escrever }}</span>
|
||||
<span class="stat-lbl">a escrever</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar (titulo, T#n, area)..." class="filter-search" />
|
||||
<select v-model="filterArea" class="filter-sel">
|
||||
<option value="all">Todas áreas</option>
|
||||
<option v-for="a in areasList" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
<select v-model="filterCategoria" class="filter-sel">
|
||||
<option value="all">Todas categorias</option>
|
||||
<option v-for="c in CATEGORIA_OPTIONS" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="falhando">Falhando</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="a_escrever">A escrever</option>
|
||||
<option value="obsoleto">Obsoleto</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNew">
|
||||
<i class="pi pi-plus" /> Nova suíte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item com os filtros atuais.
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<article
|
||||
v-for="(item, idx) in itemsFiltered"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'test-item',
|
||||
{ open: openRow === item.id, dragging: drag.dragIdx.value === idx, 'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx }
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<header class="test-head">
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
<i
|
||||
:class="['pi', STATUS_LABEL[item.status]?.icon || 'pi-circle']"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color }"
|
||||
/>
|
||||
<div class="test-title-wrap" @click="toggle(item.id)">
|
||||
<h4 class="test-title">
|
||||
<span class="test-ref">T#{{ item.id }}</span>
|
||||
<span class="test-area">{{ item.area }}</span>
|
||||
{{ item.titulo }}
|
||||
</h4>
|
||||
<div class="test-meta">
|
||||
<span v-if="item.categoria" class="test-cat">{{ item.categoria }}</span>
|
||||
<span v-if="item.total_tests > 0" class="test-count">
|
||||
<i class="pi pi-check text-emerald-500" /> {{ item.passing }}/{{ item.total_tests }}
|
||||
<span v-if="item.failing > 0" class="text-red-500"> · {{ item.failing }} falhando</span>
|
||||
</span>
|
||||
<span v-if="item.arquivo" class="test-file">
|
||||
<i class="pi pi-file text-xs" /> {{ item.arquivo }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="test-status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="openEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<i
|
||||
:class="['pi pi-chevron-down test-chev', { open: openRow === item.id }]"
|
||||
@click="toggle(item.id)"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div v-if="openRow === item.id" class="test-body">
|
||||
<div v-if="item.descricao" class="test-section">
|
||||
<h5>Cobertura</h5>
|
||||
<p>{{ item.descricao }}</p>
|
||||
</div>
|
||||
<div v-if="item.notas" class="test-section">
|
||||
<h5>Notas</h5>
|
||||
<p>{{ item.notas }}</p>
|
||||
</div>
|
||||
<div v-if="item.last_run_at || item.sessao_criacao" class="test-section">
|
||||
<h5>Execução</h5>
|
||||
<p>
|
||||
<span v-if="item.last_run_at">Última execução: {{ formatDateTime(item.last_run_at) }}</span>
|
||||
<span v-if="item.sessao_criacao" class="text-[var(--text-color-secondary)]">
|
||||
<span v-if="item.last_run_at"> — </span>Criado na {{ item.sessao_criacao }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="item.cobertura_pct !== null" class="test-section">
|
||||
<h5>Cobertura estimada</h5>
|
||||
<p>{{ item.cobertura_pct }}%</p>
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="test-tags">
|
||||
<span v-for="tag in item.tags" :key="tag" class="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Drawer -->
|
||||
<DevDrawer
|
||||
:open="drawerOpen"
|
||||
:title="editingId ? 'Editar suíte' : 'Nova suíte'"
|
||||
:subtitle="editingId ? `T#${editingId}` : 'Preencha os campos e salve'"
|
||||
:can-save="!!form.titulo.trim() && !!form.area.trim()"
|
||||
:saving="saving"
|
||||
:danger="!!editingId"
|
||||
@close="drawerOpen = false"
|
||||
@save="save"
|
||||
@delete="askDelete"
|
||||
>
|
||||
<div class="form-row">
|
||||
<DevField label="Área" required hint="Ex.: agenda, auth, pacientes, e2e">
|
||||
<input v-model="form.area" list="area-suggestions-tests" placeholder="área" />
|
||||
<datalist id="area-suggestions-tests">
|
||||
<option v-for="a in AREA_SUGGESTIONS" :key="a" :value="a" />
|
||||
</datalist>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Categoria">
|
||||
<select v-model="form.categoria">
|
||||
<option v-for="c in CATEGORIA_OPTIONS" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Título" required>
|
||||
<input v-model="form.titulo" placeholder="Ex.: useRecurrence — geração de ocorrências" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="form.status">
|
||||
<option value="ok">OK — todos passando</option>
|
||||
<option value="falhando">Falhando</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="a_escrever">A escrever</option>
|
||||
<option value="obsoleto">Obsoleto</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Arquivo" hint="src/features/agenda/.../__tests__/*.spec.js">
|
||||
<input v-model="form.arquivo" placeholder="Caminho do arquivo de teste" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Cobertura (descrição)" hint="O que essa suíte cobre">
|
||||
<textarea v-model="form.descricao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row-3">
|
||||
<DevField label="Total">
|
||||
<input v-model.number="form.total_tests" type="number" min="0" />
|
||||
</DevField>
|
||||
<DevField label="Passing">
|
||||
<input v-model.number="form.passing" type="number" min="0" />
|
||||
</DevField>
|
||||
<DevField label="Falhando">
|
||||
<input v-model.number="form.failing" type="number" min="0" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Skipped">
|
||||
<input v-model.number="form.skipped" type="number" min="0" />
|
||||
</DevField>
|
||||
<DevField label="Cobertura %">
|
||||
<input v-model.number="form.cobertura_pct" type="number" min="0" max="100" step="0.01" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Última execução">
|
||||
<input v-model="form.last_run_at" type="datetime-local" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Sessão de criação" hint="Ex.: Sessão 2 — agenda">
|
||||
<input v-model="form.sessao_criacao" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Notas">
|
||||
<textarea v-model="form.notas" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Tags" hint="Separe por vírgula">
|
||||
<input v-model="form.tags" placeholder="tag1, tag2" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.testes { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-left: 3px solid var(--c, var(--primary-color));
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-num { font-size: 22px; font-weight: 700; color: var(--text-color); line-height: 1; }
|
||||
.stat-sub { font-size: 13px; font-weight: 500; color: var(--text-color-secondary); }
|
||||
.stat-lbl { font-size: 11px; color: var(--text-color-secondary); margin-top: 4px; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 220px; }
|
||||
.filter-sel { min-width: 140px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.test-item {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.test-item:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.test-item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.test-item.drop-target { border-color: var(--primary-color); border-style: dashed; background: color-mix(in srgb, var(--primary-color) 5%, transparent); }
|
||||
|
||||
.test-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.drag-handle:hover { opacity: 1; background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.test-head > .pi:nth-child(2) { font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.test-title-wrap { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.test-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.test-ref {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-ground);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
.test-area {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.test-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.test-cat {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.test-count {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.test-file {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.test-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.test-chev {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.test-chev.open { transform: rotate(180deg); }
|
||||
|
||||
.test-body {
|
||||
padding: 0 14px 14px 42px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.test-section { margin-bottom: 10px; }
|
||||
.test-section h5 {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.test-section p {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.test-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row-3 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,732 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevVerificacoesTab.vue — Revisão sênior por área (CRUD + drag-drop)
|
||||
|--------------------------------------------------------------------------
|
||||
| Lista sessão-a-sessão de verificações de qualidade de código por área do
|
||||
| sistema. Diferente da Auditoria (bugs conhecidos), aqui registramos o que
|
||||
| já foi revisado, o que falta revisar, e o resultado de cada revisão.
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DevDrawer from './components/DevDrawer.vue';
|
||||
import DevField from './components/DevField.vue';
|
||||
import { useDraggableList, reorderWithIndexes } from './composables/useDraggableList';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(true);
|
||||
const items = ref([]);
|
||||
const filterArea = ref('all');
|
||||
const filterStatus = ref('all');
|
||||
const filterSev = ref('all');
|
||||
const search = ref('');
|
||||
const openRow = ref(null);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
area: '',
|
||||
categoria: '',
|
||||
titulo: '',
|
||||
descricao: '',
|
||||
resultado: '',
|
||||
acao_sugerida: '',
|
||||
severidade: '',
|
||||
status: 'pendente',
|
||||
verificado_em: null,
|
||||
sessao_verificacao: '',
|
||||
arquivo_afetado: '',
|
||||
auditoria_item_id: null,
|
||||
tags: ''
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pendente: { label: 'Pendente', color: '#94a3b8', bg: 'rgba(148,163,184,.12)', icon: 'pi-hourglass' },
|
||||
verificando: { label: 'Verificando', color: '#0ea5e9', bg: 'rgba(14,165,233,.12)', icon: 'pi-search' },
|
||||
ok: { label: 'OK', color: '#10b981', bg: 'rgba(16,185,129,.12)', icon: 'pi-check-circle' },
|
||||
problema: { label: 'Problema', color: '#ef4444', bg: 'rgba(239,68,68,.12)', icon: 'pi-exclamation-triangle' },
|
||||
corrigido: { label: 'Corrigido', color: '#8b5cf6', bg: 'rgba(139,92,246,.12)', icon: 'pi-verified' },
|
||||
wontfix: { label: "Won't fix", color: '#64748b', bg: 'rgba(100,116,139,.12)', icon: 'pi-ban' }
|
||||
};
|
||||
|
||||
const SEV_LABEL = {
|
||||
critico: { label: 'Crítico', color: '#dc2626' },
|
||||
alto: { label: 'Alto', color: '#f59e0b' },
|
||||
medio: { label: 'Médio', color: '#0ea5e9' },
|
||||
baixo: { label: 'Baixo', color: '#94a3b8' }
|
||||
};
|
||||
|
||||
// Áreas sugeridas — usuário pode digitar livre também
|
||||
const AREA_SUGGESTIONS = [
|
||||
'auth', 'router', 'session',
|
||||
'agenda', 'pacientes', 'financeiro', 'documentos',
|
||||
'comunicacao', 'notificacoes', 'whatsapp',
|
||||
'saas-admin', 'billing', 'portal',
|
||||
'stores', 'composables', 'ui-shared',
|
||||
'database', 'rls', 'migrations',
|
||||
'performance', 'seguranca', 'observabilidade'
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('dev_verificacoes_items')
|
||||
.select('*')
|
||||
.order('area')
|
||||
.order('ordem')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
items.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const areasList = computed(() => {
|
||||
const set = new Set();
|
||||
items.value.forEach((i) => i.area && set.add(i.area));
|
||||
AREA_SUGGESTIONS.forEach((a) => set.add(a));
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
const itemsFiltered = computed(() => {
|
||||
let list = items.value;
|
||||
if (filterArea.value !== 'all') list = list.filter((i) => i.area === filterArea.value);
|
||||
if (filterStatus.value !== 'all') list = list.filter((i) => i.status === filterStatus.value);
|
||||
if (filterSev.value !== 'all') list = list.filter((i) => i.severidade === filterSev.value);
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(i) =>
|
||||
i.titulo.toLowerCase().includes(q) ||
|
||||
(i.descricao || '').toLowerCase().includes(q) ||
|
||||
(i.resultado || '').toLowerCase().includes(q) ||
|
||||
(i.categoria || '').toLowerCase().includes(q) ||
|
||||
(i.area || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const counts = computed(() => ({
|
||||
total: items.value.length,
|
||||
pendente: items.value.filter((i) => i.status === 'pendente').length,
|
||||
verificando: items.value.filter((i) => i.status === 'verificando').length,
|
||||
ok: items.value.filter((i) => i.status === 'ok').length,
|
||||
problema: items.value.filter((i) => i.status === 'problema').length,
|
||||
corrigido: items.value.filter((i) => i.status === 'corrigido').length
|
||||
}));
|
||||
|
||||
function toggle(id) {
|
||||
openRow.value = openRow.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
if (filterArea.value !== 'all') form.value.area = filterArea.value;
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editingId.value = item.id;
|
||||
form.value = {
|
||||
area: item.area || '',
|
||||
categoria: item.categoria || '',
|
||||
titulo: item.titulo || '',
|
||||
descricao: item.descricao || '',
|
||||
resultado: item.resultado || '',
|
||||
acao_sugerida: item.acao_sugerida || '',
|
||||
severidade: item.severidade || '',
|
||||
status: item.status || 'pendente',
|
||||
verificado_em: item.verificado_em || null,
|
||||
sessao_verificacao: item.sessao_verificacao || '',
|
||||
arquivo_afetado: item.arquivo_afetado || '',
|
||||
auditoria_item_id: item.auditoria_item_id || null,
|
||||
tags: (item.tags || []).join(', ')
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.titulo.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Título obrigatório', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (!form.value.area.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Área obrigatória', life: 3000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
area: form.value.area.trim(),
|
||||
categoria: form.value.categoria.trim() || null,
|
||||
titulo: form.value.titulo.trim(),
|
||||
descricao: form.value.descricao.trim() || null,
|
||||
resultado: form.value.resultado.trim() || null,
|
||||
acao_sugerida: form.value.acao_sugerida.trim() || null,
|
||||
severidade: form.value.severidade || null,
|
||||
status: form.value.status,
|
||||
verificado_em: form.value.verificado_em || null,
|
||||
sessao_verificacao: form.value.sessao_verificacao.trim() || null,
|
||||
arquivo_afetado: form.value.arquivo_afetado.trim() || null,
|
||||
auditoria_item_id: form.value.auditoria_item_id || null,
|
||||
tags: form.value.tags
|
||||
? form.value.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: []
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
const { error } = await supabase.from('dev_verificacoes_items').update(payload).eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
payload.ordem = (items.value.filter((i) => i.area === payload.area).length || 0) + 1;
|
||||
const { error } = await supabase.from('dev_verificacoes_items').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
||||
}
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
if (!editingId.value) return;
|
||||
confirm.require({
|
||||
message: 'Tem certeza que quer excluir este item?',
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('dev_verificacoes_items').delete().eq('id', editingId.value);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
||||
drawerOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag & drop ───────────────────────────────────────────────────
|
||||
async function handleReorder(fromIdx, toIdx) {
|
||||
const hasFilter = filterArea.value !== 'all' || filterStatus.value !== 'all' || filterSev.value !== 'all' || search.value.trim();
|
||||
if (hasFilter) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Limpe os filtros pra reordenar',
|
||||
detail: 'Drag-drop só funciona com a lista completa.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
const before = items.value;
|
||||
items.value = reorderWithIndexes(before, fromIdx, toIdx);
|
||||
try {
|
||||
const updates = items.value.map((item) =>
|
||||
supabase.from('dev_verificacoes_items').update({ ordem: item.ordem }).eq('id', item.id)
|
||||
);
|
||||
await Promise.all(updates);
|
||||
} catch (e) {
|
||||
items.value = before;
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reordenar', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
const drag = useDraggableList({ onReorder: handleReorder });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner text-2xl text-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div v-else class="verificacoes">
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">{{ counts.total }}</span>
|
||||
<span class="stat-lbl">total</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #94a3b8">
|
||||
<span class="stat-num">{{ counts.pendente }}</span>
|
||||
<span class="stat-lbl">pendentes</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #0ea5e9">
|
||||
<span class="stat-num">{{ counts.verificando }}</span>
|
||||
<span class="stat-lbl">verificando</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #10b981">
|
||||
<span class="stat-num">{{ counts.ok }}</span>
|
||||
<span class="stat-lbl">OK</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #ef4444">
|
||||
<span class="stat-num">{{ counts.problema }}</span>
|
||||
<span class="stat-lbl">com problema</span>
|
||||
</div>
|
||||
<div class="stat" style="--c: #8b5cf6">
|
||||
<span class="stat-num">{{ counts.corrigido }}</span>
|
||||
<span class="stat-lbl">corrigidos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="search" placeholder="Buscar..." class="filter-search" />
|
||||
<select v-model="filterArea" class="filter-sel">
|
||||
<option value="all">Todas áreas</option>
|
||||
<option v-for="a in areasList" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="filter-sel">
|
||||
<option value="all">Todos os status</option>
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="verificando">Verificando</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="problema">Problema</option>
|
||||
<option value="corrigido">Corrigido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
</select>
|
||||
<select v-model="filterSev" class="filter-sel">
|
||||
<option value="all">Todas severidades</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="openNew">
|
||||
<i class="pi pi-plus" /> Novo item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="!itemsFiltered.length" class="empty">
|
||||
Nenhum item com os filtros atuais.
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<article
|
||||
v-for="(item, idx) in itemsFiltered"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'verif-item',
|
||||
{ open: openRow === item.id, dragging: drag.dragIdx.value === idx, 'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx }
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<header class="verif-head">
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
<i
|
||||
:class="['pi', STATUS_LABEL[item.status]?.icon || 'pi-circle']"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color }"
|
||||
/>
|
||||
<div class="verif-title-wrap" @click="toggle(item.id)">
|
||||
<h4 class="verif-title">
|
||||
<span class="verif-ref">V#{{ item.id }}</span>
|
||||
<span class="verif-area">{{ item.area }}</span>
|
||||
{{ item.titulo }}
|
||||
</h4>
|
||||
<div class="verif-meta">
|
||||
<span v-if="item.categoria" class="verif-cat">{{ item.categoria }}</span>
|
||||
<span v-if="item.severidade" :style="{ color: SEV_LABEL[item.severidade]?.color }">
|
||||
{{ SEV_LABEL[item.severidade]?.label }}
|
||||
</span>
|
||||
<span v-if="item.arquivo_afetado" class="verif-file">
|
||||
<i class="pi pi-file text-xs" /> {{ item.arquivo_afetado }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="verif-status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="openEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<i
|
||||
:class="['pi pi-chevron-down verif-chev', { open: openRow === item.id }]"
|
||||
@click="toggle(item.id)"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div v-if="openRow === item.id" class="verif-body">
|
||||
<div v-if="item.descricao" class="verif-section">
|
||||
<h5>O que verificar</h5>
|
||||
<p>{{ item.descricao }}</p>
|
||||
</div>
|
||||
<div v-if="item.resultado" class="verif-section">
|
||||
<h5>Resultado</h5>
|
||||
<p>{{ item.resultado }}</p>
|
||||
</div>
|
||||
<div v-if="item.acao_sugerida" class="verif-section">
|
||||
<h5>Ação sugerida</h5>
|
||||
<p>{{ item.acao_sugerida }}</p>
|
||||
</div>
|
||||
<div v-if="item.sessao_verificacao || item.verificado_em" class="verif-section">
|
||||
<h5>Verificação</h5>
|
||||
<p>
|
||||
<span v-if="item.sessao_verificacao">{{ item.sessao_verificacao }}</span>
|
||||
<span v-if="item.verificado_em" class="text-[var(--text-color-secondary)]">
|
||||
— {{ formatDate(item.verificado_em) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="item.auditoria_item_id" class="verif-section">
|
||||
<h5>Vinculado à Auditoria</h5>
|
||||
<p>#{{ item.auditoria_item_id }}</p>
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="verif-tags">
|
||||
<span v-for="tag in item.tags" :key="tag" class="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de edição -->
|
||||
<DevDrawer
|
||||
:open="drawerOpen"
|
||||
:title="editingId ? 'Editar verificação' : 'Nova verificação'"
|
||||
:subtitle="editingId ? `#${editingId}` : 'Preencha os campos e salve'"
|
||||
:can-save="!!form.titulo.trim() && !!form.area.trim()"
|
||||
:saving="saving"
|
||||
:danger="!!editingId"
|
||||
@close="drawerOpen = false"
|
||||
@save="save"
|
||||
@delete="askDelete"
|
||||
>
|
||||
<div class="form-row">
|
||||
<DevField label="Área" required hint="Ex.: auth, agenda, financeiro">
|
||||
<input v-model="form.area" list="area-suggestions" placeholder="área" />
|
||||
<datalist id="area-suggestions">
|
||||
<option v-for="a in AREA_SUGGESTIONS" :key="a" :value="a" />
|
||||
</datalist>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Status">
|
||||
<select v-model="form.status">
|
||||
<option value="pendente">Pendente</option>
|
||||
<option value="verificando">Verificando</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="problema">Problema</option>
|
||||
<option value="corrigido">Corrigido</option>
|
||||
<option value="wontfix">Won't fix</option>
|
||||
</select>
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Título" required>
|
||||
<input v-model="form.titulo" placeholder="Ex.: Session refresh lock não libera em exceção" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Severidade">
|
||||
<select v-model="form.severidade">
|
||||
<option value="">—</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alto">Alto</option>
|
||||
<option value="medio">Médio</option>
|
||||
<option value="baixo">Baixo</option>
|
||||
</select>
|
||||
</DevField>
|
||||
|
||||
<DevField label="Categoria" hint="Ex.: Performance, Segurança, DX">
|
||||
<input v-model="form.categoria" placeholder="Categoria" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="O que verificar" hint="Descrição do que precisa ser analisado nessa área">
|
||||
<textarea v-model="form.descricao" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Resultado" hint="O que foi encontrado ao verificar">
|
||||
<textarea v-model="form.resultado" rows="3" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Ação sugerida" hint="Como tratar o que foi encontrado">
|
||||
<textarea v-model="form.acao_sugerida" rows="2" />
|
||||
</DevField>
|
||||
|
||||
<div class="form-row">
|
||||
<DevField label="Data da verificação">
|
||||
<input v-model="form.verificado_em" type="date" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Sessão" hint="Ex.: Sessão 1 — auth/router">
|
||||
<input v-model="form.sessao_verificacao" />
|
||||
</DevField>
|
||||
</div>
|
||||
|
||||
<DevField label="Arquivo afetado" hint="src/features/agenda/...">
|
||||
<input v-model="form.arquivo_afetado" placeholder="Caminho do arquivo" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Vínculo com Auditoria" hint="ID do item em dev_auditoria_items (se virou bug)">
|
||||
<input v-model.number="form.auditoria_item_id" type="number" placeholder="ID" />
|
||||
</DevField>
|
||||
|
||||
<DevField label="Tags" hint="Separe por vírgula">
|
||||
<input v-model="form.tags" placeholder="tag1, tag2" />
|
||||
</DevField>
|
||||
</DevDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading { text-align: center; padding: 60px; }
|
||||
|
||||
.verificacoes { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-left: 3px solid var(--c, var(--primary-color));
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-num { font-size: 22px; font-weight: 700; color: var(--text-color); line-height: 1; }
|
||||
.stat-lbl { font-size: 11px; color: var(--text-color-secondary); margin-top: 4px; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-search, .filter-sel {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
.filter-search { flex: 1; min-width: 200px; }
|
||||
.filter-sel { min-width: 140px; cursor: pointer; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.verif-item {
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.verif-item:hover { border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)); }
|
||||
.verif-item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.verif-item.drop-target { border-color: var(--primary-color); border-style: dashed; background: color-mix(in srgb, var(--primary-color) 5%, transparent); }
|
||||
|
||||
.verif-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.drag-handle:hover { opacity: 1; background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.verif-head > .pi:nth-child(2) { font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.verif-title-wrap { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.verif-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.verif-area {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.verif-ref {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-ground);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
.verif-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.verif-cat {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.verif-file {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.verif-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.verif-chev {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.verif-chev.open { transform: rotate(180deg); }
|
||||
|
||||
.verif-body {
|
||||
padding: 0 14px 14px 42px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.verif-section { margin-bottom: 10px; }
|
||||
.verif-section h5 {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.verif-section p {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.verif-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--surface-card);
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/saas/development/SaasDevelopmentPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import DevOverviewTab from './DevOverviewTab.vue';
|
||||
import DevRoadmapTab from './DevRoadmapTab.vue';
|
||||
import DevEstruturaTab from './DevEstruturaTab.vue';
|
||||
import DevAuditoriaTab from './DevAuditoriaTab.vue';
|
||||
import DevVerificacoesTab from './DevVerificacoesTab.vue';
|
||||
import DevTestsTab from './DevTestsTab.vue';
|
||||
import DevCompetitorsTab from './DevCompetitorsTab.vue';
|
||||
import DevDatabaseTab from './DevDatabaseTab.vue';
|
||||
import DevExportTab from './DevExportTab.vue';
|
||||
|
||||
const activeTab = ref(0);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: 'Visão Geral', icon: 'pi-home', component: DevOverviewTab },
|
||||
{ key: 'roadmap', label: 'Roadmap', icon: 'pi-flag', component: DevRoadmapTab },
|
||||
{ key: 'estrutura', label: 'Estrutura', icon: 'pi-sitemap', component: DevEstruturaTab },
|
||||
{ key: 'auditoria', label: 'Auditoria', icon: 'pi-verified', component: DevAuditoriaTab },
|
||||
{ key: 'verificacoes', label: 'Verificações',icon: 'pi-search', component: DevVerificacoesTab },
|
||||
{ key: 'testes', label: 'Testes', icon: 'pi-check-square',component: DevTestsTab },
|
||||
{ key: 'concorrentes', label: 'Concorrentes',icon: 'pi-globe', component: DevCompetitorsTab },
|
||||
{ key: 'banco', label: 'Banco de Dados',icon: 'pi-database', component: DevDatabaseTab },
|
||||
{ key: 'export', label: 'Exportar', icon: 'pi-download', component: DevExportTab }
|
||||
];
|
||||
|
||||
const activeComponent = computed(() => tabs[activeTab.value]?.component);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<!-- Header -->
|
||||
<section class="dev-header">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-code text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-bold text-xl tracking-tight text-[var(--text-color)]">Desenvolvimento</h1>
|
||||
<p class="text-xs text-[var(--text-color-secondary)]">
|
||||
Área interna de trabalho — roadmap, auditoria, concorrentes, banco
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tabs -->
|
||||
<nav class="dev-tabs">
|
||||
<button
|
||||
v-for="(tab, idx) in tabs"
|
||||
:key="tab.key"
|
||||
:class="['dev-tab', { active: idx === activeTab }]"
|
||||
@click="activeTab = idx"
|
||||
>
|
||||
<i :class="['pi', tab.icon]" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Conteúdo da aba -->
|
||||
<section class="dev-content">
|
||||
<component :is="activeComponent" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dev-page {
|
||||
padding: 16px 20px 40px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dev-header {
|
||||
padding: 10px 14px 14px;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dev-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dev-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 13px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dev-tab:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.dev-tab.active {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.dev-tab i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dev-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| BlocoItems.vue — lista de items de um bloco de roadmap com drag-drop local
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { useDraggableList } from '../composables/useDraggableList';
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
reorderFn: { type: Function, required: true },
|
||||
onEdit: { type: Function, required: true }
|
||||
});
|
||||
|
||||
const drag = useDraggableList({
|
||||
onReorder: (from, to) => props.reorderFn(props.items, from, to)
|
||||
});
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pendente: { label: 'Pendente', color: 'var(--text-color-secondary)', bg: 'var(--surface-border)' },
|
||||
em_andamento: { label: 'Em andamento', color: '#f59e0b', bg: 'rgba(245,158,11,.15)' },
|
||||
concluido: { label: 'Concluído', color: '#10b981', bg: 'rgba(16,185,129,.15)' },
|
||||
cancelado: { label: 'Cancelado', color: '#94a3b8', bg: 'rgba(148,163,184,.15)' },
|
||||
bloqueado: { label: 'Bloqueado', color: '#ef4444', bg: 'rgba(239,68,68,.15)' }
|
||||
};
|
||||
|
||||
const PRIOR_LABEL = {
|
||||
bloqueador: { label: 'Bloqueador', color: '#ef4444' },
|
||||
alta: { label: 'Alta', color: '#f59e0b' },
|
||||
media: { label: 'Média', color: '#0ea5e9' },
|
||||
diferencial: { label: 'Diferencial', color: '#a855f7' }
|
||||
};
|
||||
|
||||
const ESFORCO_DESC = {
|
||||
S: '<1 semana',
|
||||
M: '1-3 semanas',
|
||||
L: '3-8 semanas',
|
||||
XL: '2+ meses'
|
||||
};
|
||||
const ESFORCO_COLOR = {
|
||||
S: '#10b981',
|
||||
M: '#0ea5e9',
|
||||
L: '#f59e0b',
|
||||
XL: '#ef4444'
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="items">
|
||||
<li
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'item',
|
||||
{
|
||||
dragging: drag.dragIdx.value === idx,
|
||||
'drop-target': drag.overIdx.value === idx && drag.dragIdx.value !== idx
|
||||
}
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="drag.onDragStart($event, idx)"
|
||||
@dragover.prevent="drag.onDragOver($event, idx)"
|
||||
@drop="drag.onDrop($event, idx)"
|
||||
@dragend="drag.onDragEnd"
|
||||
>
|
||||
<span class="drag-handle" title="Arrastar pra reordenar">
|
||||
<i class="pi pi-bars" />
|
||||
</span>
|
||||
|
||||
<div class="item-num">{{ item.numero || '•' }}</div>
|
||||
|
||||
<div class="item-main">
|
||||
<div class="item-head">
|
||||
<h4 class="item-feature">{{ item.feature }}</h4>
|
||||
<div class="item-badges">
|
||||
<span
|
||||
v-if="item.esforco"
|
||||
class="badge esforco"
|
||||
:style="{ color: ESFORCO_COLOR[item.esforco], borderColor: ESFORCO_COLOR[item.esforco] }"
|
||||
:title="ESFORCO_DESC[item.esforco]"
|
||||
>{{ item.esforco }}</span>
|
||||
<span
|
||||
v-if="item.prioridade"
|
||||
class="badge prior"
|
||||
:style="{ color: PRIOR_LABEL[item.prioridade]?.color }"
|
||||
>{{ PRIOR_LABEL[item.prioridade]?.label }}</span>
|
||||
<span
|
||||
class="badge status"
|
||||
:style="{ color: STATUS_LABEL[item.status]?.color, background: STATUS_LABEL[item.status]?.bg }"
|
||||
>{{ STATUS_LABEL[item.status]?.label }}</span>
|
||||
<button class="btn-icon" @click="onEdit(item)" title="Editar">
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.descricao" class="item-desc">{{ item.descricao }}</p>
|
||||
<div v-if="item.notas" class="item-notas">
|
||||
<i class="pi pi-comment text-xs" /> {{ item.notas }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.items { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 6px;
|
||||
border-bottom: 1px dashed var(--surface-border);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.item:last-child { border-bottom: none; }
|
||||
.item.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.item.drop-target {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.item:hover .drag-handle { opacity: 1; }
|
||||
.drag-handle:hover { background: var(--surface-ground); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.item-num {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-main { flex: 1; min-width: 0; }
|
||||
|
||||
.item-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-feature {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge.esforco {
|
||||
background: transparent;
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.item-notas {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevDrawer — drawer lateral reusável pra forms de edição
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Editar' },
|
||||
subtitle: { type: String, default: '' },
|
||||
width: { type: String, default: '480px' },
|
||||
canSave: { type: Boolean, default: true },
|
||||
saving: { type: Boolean, default: false },
|
||||
saveLabel: { type: String, default: 'Salvar' },
|
||||
danger: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'save', 'delete']);
|
||||
|
||||
function close() {
|
||||
if (props.saving) return;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// ESC fecha
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
document.removeEventListener('keydown', handler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="drawer">
|
||||
<div v-if="open" class="dev-drawer-wrapper">
|
||||
<div class="dev-drawer-backdrop" @click="close" />
|
||||
<aside class="dev-drawer" :style="{ width }">
|
||||
<header class="dd-head">
|
||||
<div class="dd-title-wrap">
|
||||
<h3 class="dd-title">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="dd-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button class="dd-close" @click="close" aria-label="Fechar">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="dd-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer class="dd-foot">
|
||||
<button v-if="$slots.leftAction || props.danger" class="dd-btn dd-btn-danger" @click="emit('delete')">
|
||||
<slot name="leftAction"><i class="pi pi-trash" /> Excluir</slot>
|
||||
</button>
|
||||
<div class="dd-foot-spacer" />
|
||||
<button class="dd-btn dd-btn-ghost" @click="close">Cancelar</button>
|
||||
<button
|
||||
class="dd-btn dd-btn-primary"
|
||||
:disabled="!canSave || saving"
|
||||
@click="emit('save')"
|
||||
>
|
||||
<i v-if="saving" class="pi pi-spin pi-spinner" />
|
||||
<i v-else class="pi pi-check" />
|
||||
{{ saveLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dev-drawer-wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dev-drawer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dev-drawer {
|
||||
position: relative;
|
||||
max-width: 100vw;
|
||||
background: var(--surface-card, #fff);
|
||||
border-left: 1px solid var(--surface-border, #e2e8f0);
|
||||
box-shadow: -8px 0 40px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.dd-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.dd-title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dd-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.dd-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 3px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dd-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dd-close:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dd-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.dd-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.dd-foot-spacer { flex: 1; }
|
||||
|
||||
.dd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.dd-btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
.dd-btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
.dd-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dd-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.dd-btn-ghost:hover {
|
||||
background: var(--surface-card);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dd-btn-danger {
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.dd-btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
/* Transição */
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.drawer-enter-active .dev-drawer,
|
||||
.drawer-leave-active .dev-drawer {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.drawer-enter-from .dev-drawer,
|
||||
.drawer-leave-to .dev-drawer {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| DevField — wrapper de label/input consistente pros forms de dev
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
hint: { type: String, default: '' },
|
||||
required: { type: Boolean, default: false }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dev-field">
|
||||
<label class="dev-field-label">
|
||||
{{ label }}
|
||||
<span v-if="required" class="dev-field-req">*</span>
|
||||
</label>
|
||||
<slot />
|
||||
<small v-if="hint" class="dev-field-hint">{{ hint }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dev-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dev-field-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.dev-field-req { color: #ef4444; }
|
||||
.dev-field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Styling global pros inputs dentro do field (aplicado via :deep em pais) */
|
||||
.dev-field :deep(input),
|
||||
.dev-field :deep(select),
|
||||
.dev-field :deep(textarea) {
|
||||
background: var(--surface-ground, #f8fafc);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 7px;
|
||||
padding: 8px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.dev-field :deep(input:focus),
|
||||
.dev-field :deep(select:focus),
|
||||
.dev-field :deep(textarea:focus) {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.dev-field :deep(textarea) {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dev-field :deep(select) { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* useDraggableList — drag-drop HTML5 nativo pra reordenação de listas.
|
||||
* Sem deps externas.
|
||||
*
|
||||
* Uso:
|
||||
* const { onDragStart, onDragOver, onDrop, onDragEnd, dragIdx, overIdx, isDragging } = useDraggableList({
|
||||
* onReorder: async (fromIdx, toIdx) => { ... }
|
||||
* });
|
||||
*
|
||||
* <li
|
||||
* v-for="(item, i) in list"
|
||||
* draggable="true"
|
||||
* @dragstart="onDragStart($event, i)"
|
||||
* @dragover.prevent="onDragOver($event, i)"
|
||||
* @drop="onDrop($event, i)"
|
||||
* @dragend="onDragEnd"
|
||||
* :class="{ dragging: dragIdx === i, 'drop-target': overIdx === i && dragIdx !== i }"
|
||||
* >...</li>
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useDraggableList({ onReorder } = {}) {
|
||||
const dragIdx = ref(null);
|
||||
const overIdx = ref(null);
|
||||
|
||||
const isDragging = () => dragIdx.value !== null;
|
||||
|
||||
function onDragStart(e, index) {
|
||||
dragIdx.value = index;
|
||||
overIdx.value = null;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox exige payload pra drag iniciar
|
||||
try {
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
} catch {}
|
||||
}
|
||||
// Adiciona classe pro cursor mudar
|
||||
if (e.target?.classList) e.target.classList.add('is-dragging');
|
||||
}
|
||||
|
||||
function onDragOver(e, index) {
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
overIdx.value = index;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
// Opcional: limpar overIdx quando sair da lista inteira
|
||||
}
|
||||
|
||||
async function onDrop(e, toIndex) {
|
||||
e.preventDefault();
|
||||
const fromIndex = dragIdx.value;
|
||||
dragIdx.value = null;
|
||||
overIdx.value = null;
|
||||
|
||||
if (fromIndex === null || fromIndex === toIndex) return;
|
||||
if (typeof onReorder === 'function') {
|
||||
await onReorder(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(e) {
|
||||
dragIdx.value = null;
|
||||
overIdx.value = null;
|
||||
if (e?.target?.classList) e.target.classList.remove('is-dragging');
|
||||
}
|
||||
|
||||
return {
|
||||
dragIdx,
|
||||
overIdx,
|
||||
isDragging,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordena um array local (imutável) movendo item de `from` pra `to`.
|
||||
*/
|
||||
export function reorderArray(arr, from, to) {
|
||||
const copy = [...arr];
|
||||
const [item] = copy.splice(from, 1);
|
||||
copy.splice(to, 0, item);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza o campo `ordem` de cada item após reorder, e retorna o array
|
||||
* com ordem já atualizada (para persistir no banco).
|
||||
*/
|
||||
export function reorderWithIndexes(arr, from, to, orderField = 'ordem') {
|
||||
const reordered = reorderArray(arr, from, to);
|
||||
return reordered.map((item, idx) => ({ ...item, [orderField]: idx + 1 }));
|
||||
}
|
||||
@@ -1026,7 +1026,9 @@ onMounted(async () => {
|
||||
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<div class="flex items-center justify-between mb-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon shrink-0" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-lg" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col leading-tight">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
@@ -1109,7 +1111,9 @@ onMounted(async () => {
|
||||
<!-- Agendador Online -->
|
||||
<div v-if="!loading" id="card-agendador" class="dash-card rounded-md" :class="{ '': solicitacoesPendentes > 0 }">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-inbox w-10 h-10 rounded-md cfg-subheader__icon" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-inbox text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Agendamentos Recebidos</div>
|
||||
<div class="dash-card__sub">Solicitações vindas do agendador online</div>
|
||||
@@ -1144,7 +1148,9 @@ onMounted(async () => {
|
||||
<!-- Cadastros externos -->
|
||||
<div v-if="!loading" id="card-cadastros" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-user-plus w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9">
|
||||
<i class="pi pi-user-plus text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Cadastros Recebidos (Externos)</div>
|
||||
<div class="dash-card__sub">Pacientes que preencheram seus próprios dados</div>
|
||||
@@ -1177,7 +1183,9 @@ onMounted(async () => {
|
||||
<!-- Recorrências com alerta -->
|
||||
<div v-if="!loading" id="card-recorrencias" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-refresh w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #f59e0b 15%, transparent); color: #f59e0b" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #f59e0b 15%, transparent); color: #f59e0b">
|
||||
<i class="pi pi-refresh text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Recorrências</div>
|
||||
<div class="dash-card__sub">Atenção necessária</div>
|
||||
@@ -1211,7 +1219,9 @@ onMounted(async () => {
|
||||
<!-- Radar da semana -->
|
||||
<div v-if="!loading" id="card-radar" class="dash-card">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-chart-pie w-10 h-10 rounded-md cfg-subheader__icon" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1" />
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1">
|
||||
<i class="pi pi-chart-pie text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Radar da Semana</div>
|
||||
<div class="dash-card__sub">Presença, faltas e reposições</div>
|
||||
|
||||
Reference in New Issue
Block a user