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:
parent
9bfc888d28
commit
3c61acf4d9
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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"}]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue