feat(xos): restaura forms de cadastro e adiciona seletor "Novo"

- XosCentral: botão "Novo" abre seletor (Contato / Negócio / Atividade)
- Restaura dialog de Novo Contato (perdido no rebuild anterior)
- Adiciona dialog de Nova Atividade (tipo, título, data, prioridade)
- Adiciona dialog de Novo Negócio (título, estágio, valor)
- Superset: logos v2 aplicadas via volume mount, retry de token, healthcheck 127.0.0.1
- xos_conversations: coluna status adicionada no banco

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jonas Pacheco 2026-03-24 15:15:51 -03:00
parent 9bfc888d28
commit 3c61acf4d9
9 changed files with 255 additions and 6 deletions

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "wouter"; import { Link } from "wouter";
import { BrowserFrame } from "@/components/Browser/BrowserFrame"; import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import { import {
@ -13,6 +13,8 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
interface XosStats { interface XosStats {
total_contacts: number; total_contacts: number;
@ -66,6 +68,68 @@ interface Activity {
export default function XosCentral() { export default function XosCentral() {
const [activeTab, setActiveTab] = useState("dashboard"); const [activeTab, setActiveTab] = useState("dashboard");
const [isNewContactOpen, setIsNewContactOpen] = useState(false);
const [newContact, setNewContact] = useState({ name: "", email: "", phone: "", company: "", position: "" });
const [isNewActivityOpen, setIsNewActivityOpen] = useState(false);
const [newActivity, setNewActivity] = useState({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
const [isNewSelectorOpen, setIsNewSelectorOpen] = useState(false);
const [isNewDealOpen, setIsNewDealOpen] = useState(false);
const [newDeal, setNewDeal] = useState({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
const queryClient = useQueryClient();
const createContactMutation = useMutation({
mutationFn: async (data: typeof newContact) => {
const res = await fetch("/api/xos/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar contato");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/contacts"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewContactOpen(false);
setNewContact({ name: "", email: "", phone: "", company: "", position: "" });
},
});
const createDealMutation = useMutation({
mutationFn: async (data: typeof newDeal) => {
const res = await fetch("/api/xos/deals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar negócio");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/deals"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewDealOpen(false);
setNewDeal({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
},
});
const createActivityMutation = useMutation({
mutationFn: async (data: typeof newActivity) => {
const res = await fetch("/api/xos/activities", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar atividade");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/activities"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewActivityOpen(false);
setNewActivity({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
},
});
const { data: stats } = useQuery<XosStats>({ const { data: stats } = useQuery<XosStats>({
queryKey: ["/api/xos/stats"], queryKey: ["/api/xos/stats"],
@ -177,7 +241,7 @@ export default function XosCentral() {
</span> </span>
)} )}
</Button> </Button>
<Button data-testid="button-new-contact"> <Button data-testid="button-new-contact" onClick={() => setIsNewSelectorOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" /> <PlusCircle className="h-4 w-4 mr-2" />
Novo Novo
</Button> </Button>
@ -406,7 +470,7 @@ export default function XosCentral() {
<Filter className="h-4 w-4 mr-2" /> <Filter className="h-4 w-4 mr-2" />
Filtros Filtros
</Button> </Button>
<Button size="sm"> <Button size="sm" onClick={() => setIsNewContactOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" /> <PlusCircle className="h-4 w-4 mr-2" />
Novo Contato Novo Contato
</Button> </Button>
@ -478,7 +542,7 @@ export default function XosCentral() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>Atividades</CardTitle> <CardTitle>Atividades</CardTitle>
<Button size="sm"> <Button size="sm" onClick={() => setIsNewActivityOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" /> <PlusCircle className="h-4 w-4 mr-2" />
Nova Atividade Nova Atividade
</Button> </Button>
@ -520,6 +584,175 @@ export default function XosCentral() {
</Tabs> </Tabs>
</main> </main>
</div> </div>
{/* Seletor: o que deseja criar? */}
<Dialog open={isNewSelectorOpen} onOpenChange={setIsNewSelectorOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>O que deseja criar?</DialogTitle>
</DialogHeader>
<div className="grid gap-3 pt-2">
<button
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-blue-50 hover:border-blue-300 transition-colors text-left"
onClick={() => { setIsNewSelectorOpen(false); setIsNewContactOpen(true); }}
>
<div className="bg-blue-100 p-2 rounded-lg"><Users className="h-5 w-5 text-blue-600" /></div>
<div><p className="font-semibold text-slate-800">Contato</p><p className="text-sm text-slate-500">Adicionar lead ou cliente</p></div>
</button>
<button
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-emerald-50 hover:border-emerald-300 transition-colors text-left"
onClick={() => { setIsNewSelectorOpen(false); setIsNewDealOpen(true); }}
>
<div className="bg-emerald-100 p-2 rounded-lg"><TrendingUp className="h-5 w-5 text-emerald-600" /></div>
<div><p className="font-semibold text-slate-800">Negócio</p><p className="text-sm text-slate-500">Criar oportunidade de venda</p></div>
</button>
<button
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-violet-50 hover:border-violet-300 transition-colors text-left"
onClick={() => { setIsNewSelectorOpen(false); setIsNewActivityOpen(true); }}
>
<div className="bg-violet-100 p-2 rounded-lg"><Calendar className="h-5 w-5 text-violet-600" /></div>
<div><p className="font-semibold text-slate-800">Atividade</p><p className="text-sm text-slate-500">Agendar tarefa ou reunião</p></div>
</button>
</div>
</DialogContent>
</Dialog>
{/* Dialog Novo Negócio */}
<Dialog open={isNewDealOpen} onOpenChange={setIsNewDealOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo Negócio</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label>Título *</Label>
<Input placeholder="Ex: Proposta comercial Empresa X" value={newDeal.title} onChange={(e) => setNewDeal({ ...newDeal, title: e.target.value })} />
</div>
<div>
<Label>Estágio</Label>
<select className="w-full mt-1 border rounded-md px-3 py-2 text-sm" value={newDeal.stage_id} onChange={(e) => setNewDeal({ ...newDeal, stage_id: e.target.value })}>
<option value="1">Prospecção</option>
<option value="2">Qualificação</option>
<option value="3">Proposta</option>
<option value="4">Negociação</option>
<option value="5">Ganho</option>
<option value="6">Perdido</option>
</select>
</div>
<div>
<Label>Valor (R$)</Label>
<Input type="number" placeholder="0,00" value={newDeal.value} onChange={(e) => setNewDeal({ ...newDeal, value: e.target.value })} />
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setIsNewDealOpen(false)}>Cancelar</Button>
<Button
className="flex-1"
onClick={() => createDealMutation.mutate(newDeal)}
disabled={!newDeal.title || createDealMutation.isPending}
>
{createDealMutation.isPending ? "Salvando..." : "Salvar Negócio"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isNewContactOpen} onOpenChange={setIsNewContactOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo Contato</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label>Nome *</Label>
<Input placeholder="Nome completo" value={newContact.name} onChange={(e) => setNewContact({ ...newContact, name: e.target.value })} />
</div>
<div>
<Label>E-mail</Label>
<Input placeholder="email@empresa.com" value={newContact.email} onChange={(e) => setNewContact({ ...newContact, email: e.target.value })} />
</div>
<div>
<Label>Telefone / WhatsApp</Label>
<Input placeholder="(11) 99999-9999" value={newContact.phone} onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })} />
</div>
<div>
<Label>Empresa</Label>
<Input placeholder="Nome da empresa" value={newContact.company} onChange={(e) => setNewContact({ ...newContact, company: e.target.value })} />
</div>
<div>
<Label>Cargo</Label>
<Input placeholder="Cargo ou função" value={newContact.position} onChange={(e) => setNewContact({ ...newContact, position: e.target.value })} />
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setIsNewContactOpen(false)}>Cancelar</Button>
<Button
className="flex-1"
onClick={() => createContactMutation.mutate(newContact)}
disabled={!newContact.name || createContactMutation.isPending}
>
{createContactMutation.isPending ? "Salvando..." : "Salvar Contato"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isNewActivityOpen} onOpenChange={setIsNewActivityOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nova Atividade</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label>Tipo</Label>
<select
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
value={newActivity.type}
onChange={(e) => setNewActivity({ ...newActivity, type: e.target.value })}
>
<option value="task">Tarefa</option>
<option value="call">Ligação</option>
<option value="email">E-mail</option>
<option value="meeting">Reunião</option>
</select>
</div>
<div>
<Label>Título *</Label>
<Input placeholder="Descreva a atividade" value={newActivity.title} onChange={(e) => setNewActivity({ ...newActivity, title: e.target.value })} />
</div>
<div>
<Label>Descrição</Label>
<Input placeholder="Detalhes adicionais" value={newActivity.description} onChange={(e) => setNewActivity({ ...newActivity, description: e.target.value })} />
</div>
<div>
<Label>Data prevista</Label>
<Input type="datetime-local" value={newActivity.due_at} onChange={(e) => setNewActivity({ ...newActivity, due_at: e.target.value })} />
</div>
<div>
<Label>Prioridade</Label>
<select
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
value={newActivity.priority}
onChange={(e) => setNewActivity({ ...newActivity, priority: e.target.value })}
>
<option value="low">Baixa</option>
<option value="normal">Normal</option>
<option value="high">Alta</option>
</select>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setIsNewActivityOpen(false)}>Cancelar</Button>
<Button
className="flex-1"
onClick={() => createActivityMutation.mutate(newActivity)}
disabled={!newActivity.title || createActivityMutation.isPending}
>
{createActivityMutation.isPending ? "Salvando..." : "Salvar Atividade"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</BrowserFrame> </BrowserFrame>
); );
} }

View File

@ -78,7 +78,7 @@ services:
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-} ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-} ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000/api/health || exit 1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@ -372,6 +372,11 @@ services:
volumes: volumes:
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro - ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
- ./docker/superset/init.sh:/app/docker/init.sh:ro - ./docker/superset/init.sh:/app/docker/init.sh:ro
- ./docker/superset/images/arcadia_logo.png:/app/superset/static/assets/images/arcadia_logo.png:ro
- ./docker/superset/images/arcadia_logo_dark.png:/app/superset/static/assets/images/arcadia_logo_dark.png:ro
- ./docker/superset/images/superset-logo-horiz.png:/app/superset/static/assets/images/superset-logo-horiz.png:ro
- ./docker/superset/images/favicon.png:/app/superset/static/assets/images/favicon.png:ro
- ./docker/superset/images/arcadia_favicon.png:/app/superset/static/assets/images/arcadia_favicon.png:ro
- superset_home:/app/superset_home - superset_home:/app/superset_home
depends_on: depends_on:
db: db:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -46,6 +46,8 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
# ── Branding Arcádia ────────────────────────────────────────────────────────── # ── Branding Arcádia ──────────────────────────────────────────────────────────
APP_NAME = "Arcádia Insights" APP_NAME = "Arcádia Insights"
APP_ICON = "/static/assets/images/arcadia_logo.png"
APP_ICON_WIDTH = 150
LOGO_TARGET_PATH = "/" LOGO_TARGET_PATH = "/"
FAVICONS = [{"href": "/static/assets/images/favicon.png"}] FAVICONS = [{"href": "/static/assets/images/favicon.png"}]

View File

@ -69,10 +69,19 @@ export function registerSupersetRoutes(app: Express): void {
// Resolve slug → dashboard ID → embedded UUID // Resolve slug → dashboard ID → embedded UUID
// Enviar session cookie junto com o Bearer JWT para que Flask-Login identifique // Enviar session cookie junto com o Bearer JWT para que Flask-Login identifique
// o admin via user_loader (necessário quando o papel Public tem can_read on Dashboard) // o admin via user_loader (necessário quando o papel Public tem can_read on Dashboard)
const dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, { let dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
headers: { "Authorization": `Bearer ${token}`, "Cookie": session }, headers: { "Authorization": `Bearer ${token}`, "Cookie": session },
signal: AbortSignal.timeout(FETCH_TIMEOUT), signal: AbortSignal.timeout(FETCH_TIMEOUT),
}); });
// Token pode ter expirado — limpa cache e tenta uma vez com credenciais frescas
if (!dashResp.ok) {
clearTokenCache();
const fresh = await getServiceToken();
dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
headers: { "Authorization": `Bearer ${fresh.token}`, "Cookie": fresh.session },
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
}
if (!dashResp.ok) { if (!dashResp.ok) {
return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` }); return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` });
} }