7c20b518d4
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>
1088 lines
36 KiB
Vue
1088 lines
36 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| DevCompetitorsTab.vue — Concorrentes + features + matriz (CRUD)
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<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';
|
|
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
|
|
const loading = ref(true);
|
|
const viewMode = ref('competitors'); // 'competitors' | 'matrix'
|
|
const competitors = ref([]);
|
|
const features = ref([]);
|
|
const matrix = ref([]);
|
|
const openCompetitor = ref(null);
|
|
const matrixSearch = ref('');
|
|
const matrixFilter = ref('all');
|
|
|
|
// Drawers
|
|
const compDrawerOpen = ref(false);
|
|
const compSaving = ref(false);
|
|
const compEditingId = ref(null);
|
|
const compForm = ref(emptyCompForm());
|
|
|
|
const featDrawerOpen = ref(false);
|
|
const featSaving = ref(false);
|
|
const featEditingId = ref(null);
|
|
const featForm = ref(emptyFeatForm());
|
|
|
|
const matDrawerOpen = ref(false);
|
|
const matSaving = ref(false);
|
|
const matEditingId = ref(null);
|
|
const matForm = ref(emptyMatForm());
|
|
|
|
function emptyCompForm() {
|
|
return {
|
|
slug: '',
|
|
nome: '',
|
|
pais: 'BR',
|
|
foco: '',
|
|
pricing: '',
|
|
posicionamento: '',
|
|
url: '',
|
|
ultima_pesquisa: null,
|
|
notas: '',
|
|
ativo: true
|
|
};
|
|
}
|
|
|
|
function emptyFeatForm() {
|
|
return {
|
|
competitor_id: null,
|
|
categoria: '',
|
|
nome: '',
|
|
descricao: '',
|
|
fonte: 'publico',
|
|
fonte_url: '',
|
|
data_fonte: null,
|
|
destaque: false
|
|
};
|
|
}
|
|
|
|
function emptyMatForm() {
|
|
return {
|
|
dominio: '',
|
|
feature: '',
|
|
nosso_status: 'a_definir',
|
|
nossa_nota: '',
|
|
importancia: 'media'
|
|
};
|
|
}
|
|
|
|
const FONTE_LABEL = {
|
|
fetched: { label: 'Web', color: '#10b981' },
|
|
observacao: { label: 'Observado', color: '#0ea5e9' },
|
|
publico: { label: 'Público', color: '#94a3b8' },
|
|
hipotese: { label: 'Hipótese', color: '#f59e0b' }
|
|
};
|
|
|
|
const STATUS_LABEL = {
|
|
tem: { label: 'Temos', color: '#10b981', bg: 'rgba(16,185,129,.12)' },
|
|
parcial: { label: 'Parcial', color: '#f59e0b', bg: 'rgba(245,158,11,.12)' },
|
|
gap: { label: 'Gap', color: '#ef4444', bg: 'rgba(239,68,68,.12)' },
|
|
na: { label: 'N/A', color: '#94a3b8', bg: 'rgba(148,163,184,.12)' },
|
|
a_definir: { label: 'A definir', color: '#a78bfa', bg: 'rgba(167,139,250,.12)' }
|
|
};
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
try {
|
|
const [compRes, featRes, matRes] = await Promise.all([
|
|
supabase.from('dev_competitors').select('*').order('ordem'),
|
|
supabase.from('dev_competitor_features').select('*').order('ordem'),
|
|
supabase.from('dev_comparison_matrix').select('*').order('ordem')
|
|
]);
|
|
if (compRes.error) throw compRes.error;
|
|
if (featRes.error) throw featRes.error;
|
|
if (matRes.error) throw matRes.error;
|
|
competitors.value = compRes.data || [];
|
|
features.value = featRes.data || [];
|
|
matrix.value = matRes.data || [];
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function featuresOf(competitorId) {
|
|
return features.value.filter((f) => f.competitor_id === competitorId);
|
|
}
|
|
|
|
function featuresByCategory(competitorId) {
|
|
const groups = {};
|
|
for (const f of featuresOf(competitorId)) {
|
|
const cat = f.categoria || 'Outros';
|
|
if (!groups[cat]) groups[cat] = [];
|
|
groups[cat].push(f);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function toggle(id) {
|
|
openCompetitor.value = openCompetitor.value === id ? null : id;
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleDateString('pt-BR');
|
|
}
|
|
|
|
// ── CRUD Competitor ─────────────────────────────────────────────
|
|
function openNewComp() {
|
|
compEditingId.value = null;
|
|
compForm.value = emptyCompForm();
|
|
compDrawerOpen.value = true;
|
|
}
|
|
|
|
function openEditComp(comp) {
|
|
compEditingId.value = comp.id;
|
|
compForm.value = { ...comp, ativo: !!comp.ativo };
|
|
compDrawerOpen.value = true;
|
|
}
|
|
|
|
async function saveComp() {
|
|
if (!compForm.value.nome.trim() || !compForm.value.slug.trim()) return;
|
|
compSaving.value = true;
|
|
try {
|
|
const payload = {
|
|
slug: compForm.value.slug.trim(),
|
|
nome: compForm.value.nome.trim(),
|
|
pais: compForm.value.pais || null,
|
|
foco: compForm.value.foco.trim() || null,
|
|
pricing: compForm.value.pricing.trim() || null,
|
|
posicionamento: compForm.value.posicionamento.trim() || null,
|
|
url: compForm.value.url.trim() || null,
|
|
ultima_pesquisa: compForm.value.ultima_pesquisa || null,
|
|
notas: compForm.value.notas.trim() || null,
|
|
ativo: compForm.value.ativo
|
|
};
|
|
if (compEditingId.value) {
|
|
const { error } = await supabase.from('dev_competitors').update(payload).eq('id', compEditingId.value);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
|
} else {
|
|
payload.ordem = (competitors.value.length || 0) + 1;
|
|
const { error } = await supabase.from('dev_competitors').insert(payload);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
|
}
|
|
compDrawerOpen.value = false;
|
|
await load();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
} finally {
|
|
compSaving.value = false;
|
|
}
|
|
}
|
|
|
|
function askDeleteComp() {
|
|
if (!compEditingId.value) return;
|
|
const fCount = featuresOf(compEditingId.value).length;
|
|
confirm.require({
|
|
message: `Excluir este concorrente? ${fCount ? `Também vai excluir ${fCount} feature(s).` : ''}`,
|
|
header: 'Confirmar',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptClass: 'p-button-danger',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await supabase.from('dev_competitors').delete().eq('id', compEditingId.value);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
|
compDrawerOpen.value = false;
|
|
openCompetitor.value = null;
|
|
await load();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── CRUD Feature ────────────────────────────────────────────────
|
|
function openNewFeat(compId) {
|
|
featEditingId.value = null;
|
|
featForm.value = emptyFeatForm();
|
|
featForm.value.competitor_id = compId;
|
|
featDrawerOpen.value = true;
|
|
}
|
|
|
|
function openEditFeat(f) {
|
|
featEditingId.value = f.id;
|
|
featForm.value = { ...f, destaque: !!f.destaque };
|
|
featDrawerOpen.value = true;
|
|
}
|
|
|
|
async function saveFeat() {
|
|
if (!featForm.value.nome.trim() || !featForm.value.competitor_id) return;
|
|
featSaving.value = true;
|
|
try {
|
|
const payload = {
|
|
competitor_id: featForm.value.competitor_id,
|
|
categoria: featForm.value.categoria.trim() || null,
|
|
nome: featForm.value.nome.trim(),
|
|
descricao: featForm.value.descricao.trim() || null,
|
|
fonte: featForm.value.fonte || 'publico',
|
|
fonte_url: featForm.value.fonte_url.trim() || null,
|
|
data_fonte: featForm.value.data_fonte || null,
|
|
destaque: featForm.value.destaque
|
|
};
|
|
if (featEditingId.value) {
|
|
const { error } = await supabase
|
|
.from('dev_competitor_features')
|
|
.update(payload)
|
|
.eq('id', featEditingId.value);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
|
} else {
|
|
const maxOrdem = Math.max(
|
|
0,
|
|
...featuresOf(payload.competitor_id).map((f) => f.ordem || 0)
|
|
);
|
|
payload.ordem = maxOrdem + 1;
|
|
const { error } = await supabase.from('dev_competitor_features').insert(payload);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
|
}
|
|
featDrawerOpen.value = false;
|
|
await load();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
} finally {
|
|
featSaving.value = false;
|
|
}
|
|
}
|
|
|
|
function askDeleteFeat() {
|
|
if (!featEditingId.value) return;
|
|
confirm.require({
|
|
message: 'Excluir esta feature?',
|
|
header: 'Confirmar',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptClass: 'p-button-danger',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await supabase
|
|
.from('dev_competitor_features')
|
|
.delete()
|
|
.eq('id', featEditingId.value);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
|
featDrawerOpen.value = false;
|
|
await load();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── CRUD Matrix ─────────────────────────────────────────────────
|
|
function openNewMat() {
|
|
matEditingId.value = null;
|
|
matForm.value = emptyMatForm();
|
|
matDrawerOpen.value = true;
|
|
}
|
|
|
|
function openEditMat(m) {
|
|
matEditingId.value = m.id;
|
|
matForm.value = { ...m };
|
|
matDrawerOpen.value = true;
|
|
}
|
|
|
|
async function saveMat() {
|
|
if (!matForm.value.feature.trim()) return;
|
|
matSaving.value = true;
|
|
try {
|
|
const payload = {
|
|
dominio: matForm.value.dominio.trim() || null,
|
|
feature: matForm.value.feature.trim(),
|
|
nosso_status: matForm.value.nosso_status,
|
|
nossa_nota: matForm.value.nossa_nota.trim() || null,
|
|
importancia: matForm.value.importancia || null
|
|
};
|
|
if (matEditingId.value) {
|
|
const { error } = await supabase
|
|
.from('dev_comparison_matrix')
|
|
.update(payload)
|
|
.eq('id', matEditingId.value);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
|
} else {
|
|
payload.ordem = (matrix.value.length || 0) + 1;
|
|
const { error } = await supabase.from('dev_comparison_matrix').insert(payload);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Criado', life: 2000 });
|
|
}
|
|
matDrawerOpen.value = false;
|
|
await load();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
} finally {
|
|
matSaving.value = false;
|
|
}
|
|
}
|
|
|
|
function askDeleteMat() {
|
|
if (!matEditingId.value) return;
|
|
confirm.require({
|
|
message: 'Excluir esta linha da matriz?',
|
|
header: 'Confirmar',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptClass: 'p-button-danger',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await supabase
|
|
.from('dev_comparison_matrix')
|
|
.delete()
|
|
.eq('id', matEditingId.value);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Excluído', life: 2000 });
|
|
matDrawerOpen.value = false;
|
|
await load();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mudança rápida de status na matriz (inline)
|
|
async function quickUpdateMatStatus(row, newStatus) {
|
|
const prev = row.nosso_status;
|
|
row.nosso_status = newStatus;
|
|
try {
|
|
const { error } = await supabase
|
|
.from('dev_comparison_matrix')
|
|
.update({ nosso_status: newStatus })
|
|
.eq('id', row.id);
|
|
if (error) throw error;
|
|
} catch (e) {
|
|
row.nosso_status = prev;
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
|
}
|
|
}
|
|
|
|
const matrixFiltered = computed(() => {
|
|
let list = matrix.value;
|
|
if (matrixFilter.value !== 'all') list = list.filter((m) => m.nosso_status === matrixFilter.value);
|
|
if (matrixSearch.value.trim()) {
|
|
const q = matrixSearch.value.toLowerCase();
|
|
list = list.filter(
|
|
(m) =>
|
|
m.feature.toLowerCase().includes(q) ||
|
|
(m.dominio || '').toLowerCase().includes(q) ||
|
|
(m.nossa_nota || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return list;
|
|
});
|
|
|
|
const matrixByDomain = computed(() => {
|
|
const groups = {};
|
|
for (const m of matrixFiltered.value) {
|
|
const key = m.dominio || 'Outros';
|
|
if (!groups[key]) groups[key] = [];
|
|
groups[key].push(m);
|
|
}
|
|
return groups;
|
|
});
|
|
|
|
const matrixCounts = computed(() => {
|
|
const c = { tem: 0, parcial: 0, gap: 0, a_definir: 0, na: 0, total: matrix.value.length };
|
|
for (const m of matrix.value) c[m.nosso_status] = (c[m.nosso_status] || 0) + 1;
|
|
return c;
|
|
});
|
|
|
|
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="competitors">
|
|
<!-- Toggle -->
|
|
<div class="view-toggle">
|
|
<button :class="['toggle-btn', { active: viewMode === 'competitors' }]" @click="viewMode = 'competitors'">
|
|
<i class="pi pi-users" /> Concorrentes ({{ competitors.length }})
|
|
</button>
|
|
<button :class="['toggle-btn', { active: viewMode === 'matrix' }]" @click="viewMode = 'matrix'">
|
|
<i class="pi pi-table" /> Matriz de gaps ({{ matrix.length }})
|
|
</button>
|
|
<div class="toggle-spacer" />
|
|
<button v-if="viewMode === 'competitors'" class="btn-primary" @click="openNewComp">
|
|
<i class="pi pi-plus" /> Novo concorrente
|
|
</button>
|
|
<button v-else class="btn-primary" @click="openNewMat">
|
|
<i class="pi pi-plus" /> Nova linha
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Concorrentes -->
|
|
<div v-if="viewMode === 'competitors'" class="comp-list">
|
|
<article
|
|
v-for="comp in competitors"
|
|
:key="comp.id"
|
|
:class="['comp-card', { open: openCompetitor === comp.id }]"
|
|
>
|
|
<header class="comp-head">
|
|
<div class="comp-identity" @click="toggle(comp.id)">
|
|
<div class="comp-flag">{{ comp.pais }}</div>
|
|
<div>
|
|
<h3 class="comp-name">{{ comp.nome }}</h3>
|
|
<p class="comp-foco">{{ comp.foco }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="comp-stats">
|
|
<span class="comp-feat-count">{{ featuresOf(comp.id).length }} features</span>
|
|
<span v-if="comp.ultima_pesquisa" class="comp-fetch">
|
|
<i class="pi pi-refresh text-xs" /> {{ formatDate(comp.ultima_pesquisa) }}
|
|
</span>
|
|
</div>
|
|
<button class="btn-icon" @click="openEditComp(comp)" title="Editar">
|
|
<i class="pi pi-pencil" />
|
|
</button>
|
|
<i
|
|
:class="['pi pi-chevron-down comp-chev', { open: openCompetitor === comp.id }]"
|
|
@click="toggle(comp.id)"
|
|
/>
|
|
</header>
|
|
|
|
<div v-if="openCompetitor === comp.id" class="comp-body">
|
|
<div v-if="comp.posicionamento" class="comp-pos">
|
|
<h5>Posicionamento</h5>
|
|
<p>{{ comp.posicionamento }}</p>
|
|
</div>
|
|
<div v-if="comp.pricing" class="comp-pricing">
|
|
<h5>Pricing</h5>
|
|
<p>{{ comp.pricing }}</p>
|
|
</div>
|
|
<div v-if="comp.notas">
|
|
<h5>Notas</h5>
|
|
<p>{{ comp.notas }}</p>
|
|
</div>
|
|
<div v-if="comp.url">
|
|
<a :href="comp.url" target="_blank" class="comp-url">
|
|
<i class="pi pi-external-link text-xs" /> {{ comp.url }}
|
|
</a>
|
|
</div>
|
|
|
|
<div class="comp-features">
|
|
<div class="feat-header">
|
|
<h5>Features catalogadas</h5>
|
|
<button class="btn-ghost" @click="openNewFeat(comp.id)">
|
|
<i class="pi pi-plus" /> Adicionar feature
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="!featuresOf(comp.id).length" class="empty-small">
|
|
Nenhuma feature cadastrada ainda.
|
|
</div>
|
|
|
|
<div v-for="(feats, cat) in featuresByCategory(comp.id)" :key="cat" class="feat-group">
|
|
<h6 class="feat-cat">{{ cat }}</h6>
|
|
<ul class="feat-list">
|
|
<li
|
|
v-for="f in feats"
|
|
:key="f.id"
|
|
class="feat-item"
|
|
:class="{ destaque: f.destaque }"
|
|
>
|
|
<i v-if="f.destaque" class="pi pi-star-fill text-xs" style="color: #f59e0b" />
|
|
<span class="feat-name">{{ f.nome }}</span>
|
|
<span v-if="f.descricao" class="feat-desc">— {{ f.descricao }}</span>
|
|
<span
|
|
class="feat-fonte"
|
|
:style="{ color: FONTE_LABEL[f.fonte]?.color }"
|
|
>{{ FONTE_LABEL[f.fonte]?.label }}</span>
|
|
<button class="btn-icon-sm" @click="openEditFeat(f)" title="Editar">
|
|
<i class="pi pi-pencil" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<!-- Matriz -->
|
|
<div v-else class="matrix-view">
|
|
<div class="matrix-stats">
|
|
<div class="mst tem"><strong>{{ matrixCounts.tem }}</strong> temos</div>
|
|
<div class="mst parcial"><strong>{{ matrixCounts.parcial }}</strong> parciais</div>
|
|
<div class="mst gap"><strong>{{ matrixCounts.gap }}</strong> gaps</div>
|
|
<div class="mst a_definir"><strong>{{ matrixCounts.a_definir }}</strong> a definir</div>
|
|
<div class="mst total"><strong>{{ matrixCounts.total }}</strong> total</div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<input v-model="matrixSearch" type="search" placeholder="Buscar feature..." class="filter-search" />
|
|
<select v-model="matrixFilter" class="filter-sel">
|
|
<option value="all">Todos os status</option>
|
|
<option value="tem">Temos</option>
|
|
<option value="parcial">Parcial</option>
|
|
<option value="gap">Gap</option>
|
|
<option value="a_definir">A definir</option>
|
|
<option value="na">N/A</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-for="(rows, dom) in matrixByDomain" :key="dom" class="matrix-group">
|
|
<h3 class="mg-title">{{ dom }} <span class="mg-count">({{ rows.length }})</span></h3>
|
|
<div class="matrix-rows">
|
|
<div v-for="row in rows" :key="row.id" class="matrix-row">
|
|
<select
|
|
class="mr-status-sel"
|
|
:value="row.nosso_status"
|
|
@change="quickUpdateMatStatus(row, $event.target.value)"
|
|
:style="{ color: STATUS_LABEL[row.nosso_status]?.color, background: STATUS_LABEL[row.nosso_status]?.bg }"
|
|
title="Clique pra mudar status"
|
|
>
|
|
<option value="tem">Temos</option>
|
|
<option value="parcial">Parcial</option>
|
|
<option value="gap">Gap</option>
|
|
<option value="a_definir">A definir</option>
|
|
<option value="na">N/A</option>
|
|
</select>
|
|
<span class="mr-feature">{{ row.feature }}</span>
|
|
<span v-if="row.nossa_nota" class="mr-nota">{{ row.nossa_nota }}</span>
|
|
<button class="btn-icon-sm" @click="openEditMat(row)" title="Editar">
|
|
<i class="pi pi-pencil" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Drawer: Competitor -->
|
|
<DevDrawer
|
|
:open="compDrawerOpen"
|
|
:title="compEditingId ? 'Editar concorrente' : 'Novo concorrente'"
|
|
:can-save="!!compForm.nome.trim() && !!compForm.slug.trim()"
|
|
:saving="compSaving"
|
|
:danger="!!compEditingId"
|
|
@close="compDrawerOpen = false"
|
|
@save="saveComp"
|
|
@delete="askDeleteComp"
|
|
>
|
|
<div class="form-row">
|
|
<DevField label="Slug" required hint="Identificador único, ex.: simplepractice">
|
|
<input v-model="compForm.slug" />
|
|
</DevField>
|
|
<DevField label="País">
|
|
<input v-model="compForm.pais" placeholder="BR, EUA, CA..." />
|
|
</DevField>
|
|
</div>
|
|
|
|
<DevField label="Nome" required>
|
|
<input v-model="compForm.nome" />
|
|
</DevField>
|
|
|
|
<DevField label="Foco">
|
|
<input v-model="compForm.foco" placeholder="Ex.: Psicologia-first, Multispecialidade" />
|
|
</DevField>
|
|
|
|
<DevField label="Pricing">
|
|
<textarea v-model="compForm.pricing" rows="2" />
|
|
</DevField>
|
|
|
|
<DevField label="Posicionamento">
|
|
<textarea v-model="compForm.posicionamento" rows="3" />
|
|
</DevField>
|
|
|
|
<DevField label="URL">
|
|
<input v-model="compForm.url" type="url" />
|
|
</DevField>
|
|
|
|
<div class="form-row">
|
|
<DevField label="Última pesquisa">
|
|
<input v-model="compForm.ultima_pesquisa" type="date" />
|
|
</DevField>
|
|
<DevField label="Ativo?">
|
|
<select v-model="compForm.ativo">
|
|
<option :value="true">Sim</option>
|
|
<option :value="false">Não</option>
|
|
</select>
|
|
</DevField>
|
|
</div>
|
|
|
|
<DevField label="Notas">
|
|
<textarea v-model="compForm.notas" rows="3" />
|
|
</DevField>
|
|
</DevDrawer>
|
|
|
|
<!-- Drawer: Feature -->
|
|
<DevDrawer
|
|
:open="featDrawerOpen"
|
|
:title="featEditingId ? 'Editar feature' : 'Nova feature'"
|
|
:can-save="!!featForm.nome.trim() && !!featForm.competitor_id"
|
|
:saving="featSaving"
|
|
:danger="!!featEditingId"
|
|
@close="featDrawerOpen = false"
|
|
@save="saveFeat"
|
|
@delete="askDeleteFeat"
|
|
>
|
|
<DevField label="Concorrente" required>
|
|
<select v-model="featForm.competitor_id">
|
|
<option v-for="c in competitors" :key="c.id" :value="c.id">{{ c.nome }}</option>
|
|
</select>
|
|
</DevField>
|
|
|
|
<DevField label="Categoria" hint="Ex.: Agenda, Teleconsulta, IA, Billing">
|
|
<input v-model="featForm.categoria" />
|
|
</DevField>
|
|
|
|
<DevField label="Nome da feature" required>
|
|
<input v-model="featForm.nome" />
|
|
</DevField>
|
|
|
|
<DevField label="Descrição">
|
|
<textarea v-model="featForm.descricao" rows="3" />
|
|
</DevField>
|
|
|
|
<div class="form-row">
|
|
<DevField label="Fonte">
|
|
<select v-model="featForm.fonte">
|
|
<option value="fetched">Web (scraping)</option>
|
|
<option value="observacao">Observação</option>
|
|
<option value="publico">Público</option>
|
|
<option value="hipotese">Hipótese</option>
|
|
</select>
|
|
</DevField>
|
|
<DevField label="Data da fonte">
|
|
<input v-model="featForm.data_fonte" type="date" />
|
|
</DevField>
|
|
</div>
|
|
|
|
<DevField label="URL da fonte" hint="Se vem de uma página web">
|
|
<input v-model="featForm.fonte_url" type="url" />
|
|
</DevField>
|
|
|
|
<DevField label="Destaque?" hint="Feature diferencial do concorrente">
|
|
<select v-model="featForm.destaque">
|
|
<option :value="false">Não</option>
|
|
<option :value="true">Sim (estrela)</option>
|
|
</select>
|
|
</DevField>
|
|
</DevDrawer>
|
|
|
|
<!-- Drawer: Matrix -->
|
|
<DevDrawer
|
|
:open="matDrawerOpen"
|
|
:title="matEditingId ? 'Editar linha da matriz' : 'Nova linha de comparação'"
|
|
:can-save="!!matForm.feature.trim()"
|
|
:saving="matSaving"
|
|
:danger="!!matEditingId"
|
|
@close="matDrawerOpen = false"
|
|
@save="saveMat"
|
|
@delete="askDeleteMat"
|
|
>
|
|
<DevField label="Domínio" hint="Ex.: Agenda, Telehealth, IA">
|
|
<input v-model="matForm.dominio" />
|
|
</DevField>
|
|
|
|
<DevField label="Feature" required>
|
|
<input v-model="matForm.feature" placeholder="Ex.: Biblioteca GAD-7/PHQ-9" />
|
|
</DevField>
|
|
|
|
<div class="form-row">
|
|
<DevField label="Nosso status">
|
|
<select v-model="matForm.nosso_status">
|
|
<option value="tem">Temos</option>
|
|
<option value="parcial">Parcial</option>
|
|
<option value="gap">Gap</option>
|
|
<option value="a_definir">A definir</option>
|
|
<option value="na">N/A</option>
|
|
</select>
|
|
</DevField>
|
|
<DevField label="Importância">
|
|
<select v-model="matForm.importancia">
|
|
<option value="alta">Alta</option>
|
|
<option value="media">Média</option>
|
|
<option value="baixa">Baixa</option>
|
|
</select>
|
|
</DevField>
|
|
</div>
|
|
|
|
<DevField label="Nossa nota" hint="Por que esse status? próximos passos?">
|
|
<textarea v-model="matForm.nossa_nota" rows="3" />
|
|
</DevField>
|
|
</DevDrawer>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.loading { text-align: center; padding: 60px; }
|
|
|
|
.competitors { display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
.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); }
|
|
.toggle-spacer { flex: 1; }
|
|
|
|
.btn-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: var(--primary-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 7px;
|
|
padding: 7px 13px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.btn-primary:hover { opacity: 0.9; }
|
|
|
|
.btn-ghost {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: transparent;
|
|
color: var(--primary-color);
|
|
border: 1px dashed color-mix(in srgb, var(--primary-color) 40%, transparent);
|
|
border-radius: 6px;
|
|
padding: 5px 10px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.btn-ghost:hover {
|
|
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
|
border-style: solid;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.btn-icon-sm {
|
|
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-sm:hover {
|
|
background: var(--surface-ground);
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
/* Cards de concorrentes */
|
|
.comp-list { display: flex; flex-direction: column; gap: 10px; }
|
|
|
|
.comp-card {
|
|
background: var(--surface-card, #fff);
|
|
border: 1px solid var(--surface-border);
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.comp-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
.comp-identity {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.comp-flag {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 8px;
|
|
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
|
color: var(--primary-color);
|
|
font-weight: 700;
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.comp-name {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: var(--text-color);
|
|
margin: 0;
|
|
}
|
|
.comp-foco {
|
|
font-size: 11px;
|
|
color: var(--text-color-secondary);
|
|
margin: 2px 0 0;
|
|
}
|
|
|
|
.comp-stats {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
color: var(--text-color-secondary);
|
|
}
|
|
.comp-feat-count {
|
|
font-weight: 600;
|
|
color: var(--text-color);
|
|
font-size: 12px;
|
|
}
|
|
.comp-fetch { display: flex; align-items: center; gap: 4px; }
|
|
|
|
.comp-chev {
|
|
font-size: 11px;
|
|
color: var(--text-color-secondary);
|
|
transition: transform 0.2s;
|
|
cursor: pointer;
|
|
padding: 8px;
|
|
}
|
|
.comp-chev.open { transform: rotate(180deg); }
|
|
|
|
.comp-body {
|
|
padding: 0 16px 16px;
|
|
border-top: 1px solid var(--surface-border);
|
|
padding-top: 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.comp-body h5 {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-color-secondary);
|
|
margin-bottom: 4px;
|
|
}
|
|
.comp-body p {
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
color: var(--text-color);
|
|
margin: 0;
|
|
}
|
|
|
|
.comp-url {
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 11px;
|
|
color: var(--primary-color);
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.comp-url:hover { text-decoration: underline; }
|
|
|
|
.feat-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
.feat-header h5 { margin: 0; }
|
|
|
|
.feat-group { margin-bottom: 12px; }
|
|
.feat-cat {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: var(--primary-color);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 5px;
|
|
}
|
|
.feat-list { list-style: none; padding: 0; margin: 0; }
|
|
.feat-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 5px 0;
|
|
font-size: 12px;
|
|
border-bottom: 1px dashed var(--surface-border);
|
|
}
|
|
.feat-item:last-child { border-bottom: none; }
|
|
.feat-item.destaque .feat-name { font-weight: 600; }
|
|
.feat-name { color: var(--text-color); flex-shrink: 0; }
|
|
.feat-desc {
|
|
color: var(--text-color-secondary);
|
|
font-size: 11px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.feat-fonte {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.empty-small {
|
|
font-size: 11px;
|
|
color: var(--text-color-secondary);
|
|
padding: 10px 0;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Matriz */
|
|
.matrix-view { display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
.matrix-stats {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.mst {
|
|
font-size: 12px;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
background: var(--surface-card, #fff);
|
|
border: 1px solid var(--surface-border);
|
|
color: var(--text-color-secondary);
|
|
}
|
|
.mst strong { color: var(--text-color); margin-right: 4px; font-size: 14px; }
|
|
.mst.tem strong { color: #10b981; }
|
|
.mst.parcial strong { color: #f59e0b; }
|
|
.mst.gap strong { color: #ef4444; }
|
|
.mst.a_definir strong { color: #a78bfa; }
|
|
|
|
.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;
|
|
outline: none;
|
|
color: var(--text-color);
|
|
}
|
|
.filter-search { flex: 1; min-width: 200px; }
|
|
.filter-sel { min-width: 160px; cursor: pointer; }
|
|
|
|
.matrix-group {
|
|
background: var(--surface-card, #fff);
|
|
border: 1px solid var(--surface-border);
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
}
|
|
.mg-title {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--primary-color);
|
|
margin-bottom: 10px;
|
|
}
|
|
.mg-count { color: var(--text-color-secondary); font-weight: 400; }
|
|
|
|
.matrix-rows { display: flex; flex-direction: column; gap: 4px; }
|
|
.matrix-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 6px 0;
|
|
font-size: 12px;
|
|
border-bottom: 1px dashed var(--surface-border);
|
|
}
|
|
.matrix-row:last-child { border-bottom: none; }
|
|
|
|
.mr-status-sel {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
padding: 4px 9px;
|
|
border-radius: 10px;
|
|
min-width: 90px;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
border: none;
|
|
cursor: pointer;
|
|
outline: none;
|
|
}
|
|
|
|
.mr-feature {
|
|
font-size: 12px;
|
|
color: var(--text-color);
|
|
font-weight: 500;
|
|
flex: 1;
|
|
}
|
|
.mr-nota {
|
|
font-size: 11px;
|
|
color: var(--text-color-secondary);
|
|
font-style: italic;
|
|
flex: 1;
|
|
text-align: right;
|
|
min-width: 100px;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
</style>
|