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 { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||
import {
|
||||
|
|
@ -13,6 +13,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
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 {
|
||||
total_contacts: number;
|
||||
|
|
@ -66,6 +68,68 @@ interface Activity {
|
|||
|
||||
export default function XosCentral() {
|
||||
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>({
|
||||
queryKey: ["/api/xos/stats"],
|
||||
|
|
@ -177,7 +241,7 @@ export default function XosCentral() {
|
|||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button data-testid="button-new-contact">
|
||||
<Button data-testid="button-new-contact" onClick={() => setIsNewSelectorOpen(true)}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Novo
|
||||
</Button>
|
||||
|
|
@ -406,7 +470,7 @@ export default function XosCentral() {
|
|||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => setIsNewContactOpen(true)}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Novo Contato
|
||||
</Button>
|
||||
|
|
@ -478,7 +542,7 @@ export default function XosCentral() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Atividades</CardTitle>
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => setIsNewActivityOpen(true)}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Nova Atividade
|
||||
</Button>
|
||||
|
|
@ -520,6 +584,175 @@ export default function XosCentral() {
|
|||
</Tabs>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ services:
|
|||
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
|
||||
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
|
||||
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
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
|
@ -372,6 +372,11 @@ services:
|
|||
volumes:
|
||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py: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
|
||||
depends_on:
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
APP_NAME = "Arcádia Insights"
|
||||
APP_ICON = "/static/assets/images/arcadia_logo.png"
|
||||
APP_ICON_WIDTH = 150
|
||||
LOGO_TARGET_PATH = "/"
|
||||
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
||||
|
||||
|
|
|
|||
|
|
@ -69,10 +69,19 @@ export function registerSupersetRoutes(app: Express): void {
|
|||
// Resolve slug → dashboard ID → embedded UUID
|
||||
// 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)
|
||||
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 },
|
||||
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) {
|
||||
return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue