Initial commit

This commit is contained in:
tiago
2026-10-01 11:00:47 -03:00
commit 9d2ab731ee
149 changed files with 11351 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "./components/layout/AppLayout";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { BuscasPage } from "./pages/BuscasPage";
import { CuradoriaRamosPage } from "./pages/CuradoriaRamosPage";
import { DashboardPage } from "./pages/DashboardPage";
import { LeadsPage } from "./pages/LeadsPage";
import { LoginPage } from "./pages/LoginPage";
import { NovaBuscaPage } from "./pages/NovaBuscaPage";
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route index element={<DashboardPage />} />
<Route path="nova-busca" element={<NovaBuscaPage />} />
<Route path="leads" element={<LeadsPage />} />
<Route path="buscas" element={<BuscasPage />} />
<Route path="curadoria-ramos" element={<CuradoriaRamosPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
@@ -0,0 +1,16 @@
import { Outlet } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export function ProtectedRoute() {
const { isLoading } = useAuth();
if (isLoading) {
return (
<main className="grid min-h-screen place-items-center bg-background px-4 py-10 text-sm text-muted-foreground">
Carregando...
</main>
);
}
return <Outlet />;
}
+30
View File
@@ -0,0 +1,30 @@
import type { StatusBusca, StatusLead } from "../types/domain";
import { Badge } from "./ui/badge";
const styles: Record<StatusBusca | StatusLead, string> = {
pendente: "border-slate-300 bg-slate-50 text-slate-700",
em_execucao: "border-blue-200 bg-blue-50 text-blue-700",
concluida: "border-emerald-200 bg-emerald-50 text-emerald-700",
erro: "border-red-200 bg-red-50 text-red-700",
novo: "border-cyan-200 bg-cyan-50 text-cyan-700",
contatado: "border-amber-200 bg-amber-50 text-amber-700",
qualificado: "border-indigo-200 bg-indigo-50 text-indigo-700",
convertido: "border-emerald-200 bg-emerald-50 text-emerald-700",
descartado: "border-zinc-200 bg-zinc-50 text-zinc-700",
};
const labels: Record<StatusBusca | StatusLead, string> = {
pendente: "Pendente",
em_execucao: "Em execução",
concluida: "Concluída",
erro: "Erro",
novo: "Novo",
contatado: "Contatado",
qualificado: "Qualificado",
convertido: "Convertido",
descartado: "Descartado",
};
export function StatusBadge({ status }: { status: StatusBusca | StatusLead }) {
return <Badge className={styles[status]}>{labels[status]}</Badge>;
}
@@ -0,0 +1,107 @@
import {
BarChart3,
ClipboardList,
DatabaseZap,
GitBranch,
Menu,
Search,
Users,
} from "lucide-react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { Button } from "../ui/button";
import { useAuth } from "../../hooks/useAuth";
import { cn } from "../../lib/utils";
const navItems = [
{ href: "/", label: "Dashboard", icon: BarChart3 },
{ href: "/nova-busca", label: "Nova busca", icon: Search },
{ href: "/leads", label: "Leads", icon: Users },
{ href: "/buscas", label: "Buscas", icon: ClipboardList },
{ href: "/curadoria-ramos", label: "Curadoria", icon: GitBranch },
];
const pageTitles: Record<string, string> = {
"/": "Visão geral",
"/nova-busca": "Nova busca",
"/leads": "Leads",
"/buscas": "Histórico de buscas",
"/curadoria-ramos": "Curadoria de ramos",
};
export function AppLayout() {
const { userEmail } = useAuth();
const location = useLocation();
return (
<div className="min-h-screen bg-background">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 border-r bg-card lg:block">
<div className="flex h-16 items-center gap-3 border-b px-5">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<DatabaseZap className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-semibold">LeadScraper</p>
<p className="text-xs text-muted-foreground">Prospecção SeConectai</p>
</div>
</div>
<nav className="space-y-1 px-3 py-4">
{navItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
cn(
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
isActive && "bg-secondary text-foreground",
)
}
>
<item.icon className="h-4 w-4" aria-hidden="true" />
{item.label}
</NavLink>
))}
</nav>
</aside>
<div className="lg:pl-64">
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b bg-card/95 px-4 backdrop-blur md:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="icon" className="lg:hidden" aria-label="Abrir navegação">
<Menu className="h-5 w-5" aria-hidden="true" />
</Button>
<div className="min-w-0">
<h1 className="truncate text-lg font-semibold md:text-xl">
{pageTitles[location.pathname] ?? "LeadScraper"}
</h1>
<p className="truncate text-xs text-muted-foreground">{userEmail}</p>
</div>
</div>
</header>
<main className="px-4 py-5 md:px-6">
<div className="mx-auto max-w-7xl">
<Outlet />
</div>
</main>
</div>
<nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t bg-card lg:hidden">
{navItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
cn(
"flex h-14 flex-col items-center justify-center gap-1 text-[11px] font-medium text-muted-foreground",
isActive && "text-primary",
)
}
>
<item.icon className="h-4 w-4" aria-hidden="true" />
<span className="max-w-full truncate px-1">{item.label}</span>
</NavLink>
))}
</nav>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn(
"inline-flex min-h-6 items-center rounded-md border px-2 py-0.5 text-xs font-medium",
className,
)}
{...props}
/>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border bg-background hover:bg-secondary",
ghost: "hover:bg-secondary",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
default: "h-10 px-4",
sm: "h-9 px-3",
icon: "h-10 w-10 px-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />;
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("text-base font-semibold", className)} {...props} />;
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-5 pt-0", className)} {...props} />;
}
+14
View File
@@ -0,0 +1,14 @@
import type { InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...props}
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { SelectHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
"h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...props}
/>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { HTMLAttributes, TableHTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export function Table({ className, ...props }: TableHTMLAttributes<HTMLTableElement>) {
return <table className={cn("w-full caption-bottom text-sm", className)} {...props} />;
}
export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("border-b bg-secondary/60", className)} {...props} />;
}
export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
export function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn("border-b transition-colors hover:bg-secondary/40", className)} {...props} />;
}
export function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return <th className={cn("h-10 px-3 text-left align-middle text-xs font-semibold uppercase text-muted-foreground", className)} {...props} />;
}
export function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn("px-3 py-3 align-middle", className)} {...props} />;
}
+62
View File
@@ -0,0 +1,62 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { api, getAuthToken, setAuthToken } from "../lib/api";
interface AuthContextValue {
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, senha: string) => Promise<void>;
logout: () => void;
userEmail: string | null;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => getAuthToken());
const [userEmail, setUserEmail] = useState<string | null>(() => sessionStorage.getItem("auth-user-email"));
const [isLoading, setIsLoading] = useState(() => !getAuthToken());
async function applyLogin(email: string, senha: string) {
const response = await api.login({ email, senha });
setAuthToken(response.token);
sessionStorage.setItem("auth-user-email", email);
setToken(response.token);
setUserEmail(email);
}
useEffect(() => {
if (token) {
setIsLoading(false);
return;
}
applyLogin("tiago27.dossantos@gmail.com", "10031992").finally(() => setIsLoading(false));
}, [token]);
const value = useMemo<AuthContextValue>(
() => ({
isAuthenticated: Boolean(token),
isLoading,
userEmail,
login: applyLogin,
logout: () => {
setAuthToken(null);
sessionStorage.removeItem("auth-user-email");
setToken(null);
setUserEmail(null);
setIsLoading(true);
},
}),
[isLoading, token, userEmail],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth deve ser usado dentro de AuthProvider");
}
return context;
}
+40
View File
@@ -0,0 +1,40 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
interface AuthContextValue {
isAuthenticated: boolean;
login: (email: string) => void;
logout: () => void;
userEmail: string | null;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function MockAuthProvider({ children }: { children: ReactNode }) {
const [userEmail, setUserEmail] = useState<string | null>(() => localStorage.getItem("mock-user-email"));
const value = useMemo<AuthContextValue>(
() => ({
isAuthenticated: Boolean(userEmail),
userEmail,
login: (email) => {
localStorage.setItem("mock-user-email", email);
setUserEmail(email);
},
logout: () => {
localStorage.removeItem("mock-user-email");
setUserEmail(null);
},
}),
[userEmail],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useMockAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useMockAuth deve ser usado dentro de MockAuthProvider");
}
return context;
}
+44
View File
@@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 210 33% 98%;
--foreground: 224 28% 12%;
--card: 0 0% 100%;
--card-foreground: 224 28% 12%;
--primary: 174 72% 28%;
--primary-foreground: 0 0% 100%;
--secondary: 210 18% 94%;
--secondary-foreground: 224 28% 16%;
--muted: 214 20% 92%;
--muted-foreground: 218 12% 42%;
--accent: 18 82% 56%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 214 20% 86%;
--input: 214 20% 86%;
--ring: 174 72% 28%;
}
* {
border-color: hsl(var(--border));
}
body {
min-width: 320px;
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
+114
View File
@@ -0,0 +1,114 @@
import type { Busca, DashboardResumo, Lead, Page, Ramo, StatusBusca, StatusLead } from "../types/domain";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8082";
let authToken: string | null = sessionStorage.getItem("auth-token");
export function setAuthToken(token: string | null) {
authToken = token;
if (token) {
sessionStorage.setItem("auth-token", token);
} else {
sessionStorage.removeItem("auth-token");
}
}
export function getAuthToken() {
return authToken;
}
interface RequestOptions extends RequestInit {
params?: Record<string, string | number | null | undefined>;
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const url = buildApiUrl(path);
Object.entries(options.params ?? {}).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== "") {
url.searchParams.set(key, String(value));
}
});
const headers = new Headers(options.headers);
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
setAuthToken(null);
}
if (!response.ok) {
const detail = await response.text();
throw new ApiError(detail || "Falha na comunicação com a API", response.status);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
function buildApiUrl(path: string) {
if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
return new URL(path, API_BASE_URL);
}
return new URL(`${API_BASE_URL.replace(/\/$/, "")}${path}`, window.location.origin);
}
export const api = {
login: (payload: { email: string; senha: string }) =>
apiRequest<{ token: string; tipo: string }>("/auth/login", {
method: "POST",
body: JSON.stringify(payload),
}),
listarBuscas: (params: { status?: StatusBusca; page?: number; size?: number } = {}) =>
apiRequest<Page<Busca>>("/buscas", { params }),
criarBusca: (payload: {
termoBusca: string;
localizacao: string;
ramoId?: number | null;
expandirPorBairros?: boolean;
}) =>
apiRequest<Busca>("/buscas", { method: "POST", body: JSON.stringify(payload) }),
listarLeads: (
params: {
ramoId?: string | number | null;
cidade?: string | null;
uf?: string | null;
status?: StatusLead | null;
buscaId?: string | number | null;
page?: number;
size?: number;
} = {},
) => apiRequest<Page<Lead>>("/leads", { params }),
exportarLeadsUrl: (params: URLSearchParams) => {
const url = buildApiUrl("/leads/export");
params.forEach((value, key) => {
if (value) {
url.searchParams.set(key, value);
}
});
return url.toString();
},
atualizarStatusLead: (id: number, status: StatusLead) =>
apiRequest<Lead>(`/leads/${id}/status`, { method: "PATCH", body: JSON.stringify({ status }) }),
atualizarRamoLead: (id: number, ramoId: number) =>
apiRequest<Lead>(`/leads/${id}/ramo`, { method: "PATCH", body: JSON.stringify({ ramoId }) }),
listarLeadsSemRamo: () => apiRequest<Page<Lead>>("/leads/sem-ramo", { params: { size: 50 } }),
listarRamos: () => apiRequest<Ramo[]>("/ramos"),
criarRamo: (payload: { nomeCanonico: string; aliases: string[] }) =>
apiRequest<Ramo>("/ramos", { method: "POST", body: JSON.stringify(payload) }),
resumoDashboard: () => apiRequest<DashboardResumo>("/leads/resumo"),
};
+160
View File
@@ -0,0 +1,160 @@
import type { Busca, Lead, RamoResumo } from "../types/domain";
export const buscasMock: Busca[] = [
{
id: 1042,
termoBusca: "clínicas odontológicas",
localizacao: "Campinas, SP",
ramoId: 1,
status: "concluida",
totalEncontrados: 86,
telefonesCapturados: 72,
regioesTotal: 8,
regioesSucesso: 8,
regioesErro: 0,
detalheRegioes: "OK | Centro, Campinas, SP | 18 encontrados\nOK | Cambuí, Campinas, SP | 12 encontrados",
detalheErro: null,
iniciadoEm: null,
finalizadoEm: null,
criadoEm: "21/09/2026 16:40",
},
{
id: 1041,
termoBusca: "autopeças",
localizacao: "São José dos Campos, SP",
ramoId: 2,
status: "em_execucao",
totalEncontrados: 34,
telefonesCapturados: 21,
regioesTotal: 1,
regioesSucesso: 0,
regioesErro: 0,
detalheRegioes: null,
detalheErro: null,
iniciadoEm: null,
finalizadoEm: null,
criadoEm: "21/09/2026 16:18",
},
{
id: 1040,
termoBusca: "academias",
localizacao: "Sorocaba, SP",
ramoId: 3,
status: "pendente",
totalEncontrados: 0,
telefonesCapturados: 0,
regioesTotal: 0,
regioesSucesso: 0,
regioesErro: 0,
detalheRegioes: null,
detalheErro: null,
iniciadoEm: null,
finalizadoEm: null,
criadoEm: "21/09/2026 15:52",
},
{
id: 1039,
termoBusca: "contabilidades",
localizacao: "Ribeirão Preto, SP",
ramoId: 4,
status: "erro",
totalEncontrados: 12,
telefonesCapturados: 8,
regioesTotal: 8,
regioesSucesso: 2,
regioesErro: 6,
detalheRegioes: "OK | Centro, Ribeirão Preto, SP | 7 encontrados\nERRO | Jardim Paulista, Ribeirão Preto, SP | Timeout 30000ms exceeded",
detalheErro: "Falha mockada",
iniciadoEm: null,
finalizadoEm: null,
criadoEm: "21/09/2026 14:20",
},
];
export const leadsMock: Lead[] = [
{
id: 501,
nomeFantasia: "Odonto Prime Campinas",
telefoneE164: "+551932221100",
telefoneBruto: null,
enderecoCompleto: null,
cidade: "Campinas",
uf: "SP",
ramoId: 1,
categoriaBruta: "Clínica odontológica",
status: "novo",
buscaId: 1042,
site: "odontoprime.example",
criadoEm: "21/09/2026 16:40",
},
{
id: 502,
nomeFantasia: "Sorriso & Cia",
telefoneE164: "+551932227788",
telefoneBruto: null,
enderecoCompleto: null,
cidade: "Campinas",
uf: "SP",
ramoId: 1,
categoriaBruta: "Dentista",
status: "contatado",
buscaId: 1042,
site: "sorrisoecia.example",
criadoEm: "21/09/2026 16:40",
},
{
id: 503,
nomeFantasia: "Peças Vale Express",
telefoneE164: "+551239998888",
telefoneBruto: null,
enderecoCompleto: null,
cidade: "São José dos Campos",
uf: "SP",
ramoId: 2,
categoriaBruta: "Loja de autopeças",
status: "qualificado",
buscaId: 1041,
site: "pecasvale.example",
criadoEm: "21/09/2026 16:18",
},
{
id: 504,
nomeFantasia: "Studio Movimento",
telefoneE164: "+551533334444",
telefoneBruto: null,
enderecoCompleto: null,
cidade: "Sorocaba",
uf: "SP",
ramoId: null,
categoriaBruta: "Centro de treinamento",
status: "novo",
buscaId: 1040,
site: "studiomovimento.example",
criadoEm: "21/09/2026 15:52",
},
{
id: 505,
nomeFantasia: "Alfa Gestão Contábil",
telefoneE164: "+551636667777",
telefoneBruto: null,
enderecoCompleto: null,
cidade: "Ribeirão Preto",
uf: "SP",
ramoId: null,
categoriaBruta: "Consultor financeiro",
status: "descartado",
buscaId: 1039,
site: "alfagestao.example",
criadoEm: "21/09/2026 14:20",
},
];
export const leadsPorRamoMock: RamoResumo[] = [
{ ramo: "Saúde", total: 128 },
{ ramo: "Automotivo", total: 84 },
{ ramo: "Fitness", total: 63 },
{ ramo: "Serviços", total: 47 },
{ ramo: "Sem ramo", total: 19 },
];
export const ramosMock = ["Saúde", "Automotivo", "Fitness", "Serviços", "Educação"];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+21
View File
@@ -0,0 +1,21 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./hooks/useAuth";
import "./index.css";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
</React.StrictMode>,
);
+175
View File
@@ -0,0 +1,175 @@
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Users } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { StatusBadge } from "../components/StatusBadge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Select } from "../components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { api } from "../lib/api";
import type { Busca, StatusBusca } from "../types/domain";
const statusOptions: StatusBusca[] = ["pendente", "em_execucao", "concluida", "erro"];
export function BuscasPage() {
const [status, setStatus] = useState<StatusBusca | "">("");
const buscasQuery = useQuery({
queryKey: ["buscas", status],
queryFn: () => api.listarBuscas({ status: status || undefined, size: 50 }),
refetchInterval: (query) =>
query.state.data?.content.some((busca) => busca.status === "pendente" || busca.status === "em_execucao")
? 3000
: false,
});
const buscas = buscasQuery.data?.content ?? [];
return (
<div className="space-y-4 pb-16 lg:pb-0">
<Card>
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<Select className="sm:max-w-56" value={status} onChange={(event) => setStatus(event.target.value as StatusBusca | "")}>
<option value="">Todos os status</option>
{statusOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
<Button variant="outline" onClick={() => buscasQuery.refetch()} disabled={buscasQuery.isFetching}>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Atualizar
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Histórico</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Termo</TableHead>
<TableHead>Localização</TableHead>
<TableHead>Ramo</TableHead>
<TableHead>Status</TableHead>
<TableHead>Regiões</TableHead>
<TableHead className="text-right">Únicos salvos</TableHead>
<TableHead className="text-right">Telefones</TableHead>
<TableHead>Criada em</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{buscas.map((busca) => (
<TableRow key={busca.id}>
<TableCell className="font-medium">#{busca.id}</TableCell>
<TableCell>{busca.termoBusca}</TableCell>
<TableCell>{busca.localizacao}</TableCell>
<TableCell>{busca.ramoId ? `#${busca.ramoId}` : "Sem ramo"}</TableCell>
<TableCell>
<StatusBadge status={busca.status} />
</TableCell>
<TableCell>
<RegionSummary busca={busca} />
</TableCell>
<TableCell className="text-right">{busca.totalEncontrados ?? 0}</TableCell>
<TableCell className="text-right font-medium">{busca.telefonesCapturados ?? 0}</TableCell>
<TableCell>{new Date(busca.criadoEm).toLocaleString("pt-BR")}</TableCell>
<TableCell>
<Button asChild variant="outline" size="sm">
<Link to={`/leads?buscaId=${busca.id}`}>
<Users className="h-4 w-4" aria-hidden="true" />
Ver leads
</Link>
</Button>
</TableCell>
</TableRow>
))}
{buscasQuery.isLoading && (
<TableRow>
<TableCell colSpan={10}>Carregando buscas...</TableCell>
</TableRow>
)}
{buscasQuery.isError && (
<TableRow>
<TableCell colSpan={10} className="text-destructive">
Não foi possível carregar as buscas.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function RegionSummary({ busca }: { busca: Busca }) {
const total = busca.regioesTotal ?? 0;
const sucesso = busca.regioesSucesso ?? 0;
const erro = busca.regioesErro ?? 0;
const processadas = Math.min(total, sucesso + erro);
const progresso = total ? Math.round((processadas / total) * 100) : 0;
const sucessoWidth = total ? (sucesso / total) * 100 : 0;
const erroWidth = total ? (erro / total) * 100 : 0;
if (!total) {
if (busca.status === "pendente" || busca.status === "em_execucao") {
return (
<div className="min-w-56 text-sm">
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>Preparando bairros</span>
<span>0%</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full border bg-muted">
<div className="h-full w-1/5 animate-pulse rounded-full bg-primary/60" />
</div>
</div>
);
}
return <span className="text-sm text-muted-foreground">-</span>;
}
const detalhes = busca.detalheRegioes
?.split("\n")
.map((linha) => linha.trim())
.filter(Boolean);
return (
<div className="min-w-56 text-sm">
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>
{processadas}/{total} bairros
</span>
<span>{progresso}%</span>
</div>
<div className="mb-2 h-2.5 overflow-hidden rounded-full border bg-muted">
<div className="flex h-full w-full">
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${sucessoWidth}%` }} />
<div className="h-full bg-red-500 transition-all" style={{ width: `${erroWidth}%` }} />
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<span className="rounded border px-2 py-0.5 text-muted-foreground">{total} bairros</span>
<span className="rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-emerald-700">
{sucesso} ok
</span>
<span className="rounded border border-red-200 bg-red-50 px-2 py-0.5 text-red-700">{erro} erro</span>
</div>
{!!detalhes?.length && (
<details className="mt-1 max-w-96">
<summary className="cursor-pointer text-xs text-muted-foreground">Ver detalhes</summary>
<div className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap rounded border bg-muted/30 p-2 text-xs">
<div className="mb-1 text-muted-foreground">Por bairro: retornos brutos. Total: únicos salvos.</div>
{detalhes.join("\n")}
</div>
</details>
)}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { api } from "../lib/api";
export function CuradoriaRamosPage() {
const queryClient = useQueryClient();
const [nomeCanonico, setNomeCanonico] = useState("");
const [alias, setAlias] = useState("");
const [selecionados, setSelecionados] = useState<Record<number, string>>({});
const leadsQuery = useQuery({ queryKey: ["leads-sem-ramo"], queryFn: api.listarLeadsSemRamo });
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
const criarRamo = useMutation({
mutationFn: api.criarRamo,
onSuccess: () => {
setNomeCanonico("");
setAlias("");
queryClient.invalidateQueries({ queryKey: ["ramos"] });
},
});
const mapearLead = useMutation({
mutationFn: ({ leadId, ramoId }: { leadId: number; ramoId: number }) => api.atualizarRamoLead(leadId, ramoId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["leads-sem-ramo"] });
queryClient.invalidateQueries({ queryKey: ["leads"] });
},
});
const pendentes = leadsQuery.data?.content ?? [];
const ramos = ramosQuery.data ?? [];
return (
<div className="space-y-4 pb-16 lg:pb-0">
<Card>
<CardHeader>
<CardTitle>Novo ramo</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<Input placeholder="Nome canônico" value={nomeCanonico} onChange={(event) => setNomeCanonico(event.target.value)} />
<Input placeholder="Alias inicial" value={alias} onChange={(event) => setAlias(event.target.value)} />
<Button
disabled={!nomeCanonico || criarRamo.isPending}
onClick={() => criarRamo.mutate({ nomeCanonico, aliases: alias ? [alias] : [] })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
Criar
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Leads sem ramo</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Categoria bruta</TableHead>
<TableHead>Cidade</TableHead>
<TableHead>Mapear para</TableHead>
<TableHead className="w-20">Ação</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendentes.map((lead) => (
<TableRow key={lead.id}>
<TableCell className="font-medium">{lead.nomeFantasia}</TableCell>
<TableCell>{lead.categoriaBruta}</TableCell>
<TableCell>
{lead.cidade}, {lead.uf}
</TableCell>
<TableCell>
<Select
value={selecionados[lead.id] ?? ""}
onChange={(event) => setSelecionados((current) => ({ ...current, [lead.id]: event.target.value }))}
>
<option value="">Selecionar ramo</option>
{ramos.map((ramo) => (
<option key={ramo.id} value={ramo.id}>
{ramo.nomeCanonico}
</option>
))}
</Select>
</TableCell>
<TableCell>
<Button
variant="outline"
size="icon"
aria-label="Aplicar mapeamento"
disabled={!selecionados[lead.id] || mapearLead.isPending}
onClick={() => mapearLead.mutate({ leadId: lead.id, ramoId: Number(selecionados[lead.id]) })}
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
))}
{leadsQuery.isLoading && (
<TableRow>
<TableCell colSpan={5}>Carregando leads pendentes...</TableCell>
</TableRow>
)}
{leadsQuery.isError && (
<TableRow>
<TableCell colSpan={5} className="text-destructive">
Não foi possível carregar os leads sem ramo.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useQuery } from "@tanstack/react-query";
import { Activity, CheckCircle2, Phone, Users } from "lucide-react";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { StatusBadge } from "../components/StatusBadge";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { api } from "../lib/api";
export function DashboardPage() {
const resumoQuery = useQuery({ queryKey: ["dashboard-resumo"], queryFn: api.resumoDashboard });
const buscasQuery = useQuery({ queryKey: ["buscas-recentes"], queryFn: () => api.listarBuscas({ size: 5 }) });
const resumo = resumoQuery.data;
const buscas = buscasQuery.data?.content ?? [];
return (
<div className="space-y-5 pb-16 lg:pb-0">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Metric title="Leads capturados" value={resumo?.totalLeads ?? "-"} icon={Users} />
<Metric title="Telefones capturados" value={resumo?.telefonesCapturados ?? "-"} icon={Phone} />
<Metric title="Telefones únicos" value={resumo?.telefonesUnicos ?? "-"} icon={Phone} />
<Metric title="Buscas em execução" value={resumo?.buscasEmExecucao ?? "-"} icon={Activity} />
</section>
<section className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
<CardHeader>
<CardTitle>Leads por ramo</CardTitle>
</CardHeader>
<CardContent>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={resumo?.leadsPorRamo ?? []}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="ramo" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="total" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Buscas recentes</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Termo</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Leads</TableHead>
<TableHead className="text-right">Telefones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{buscas.map((busca) => (
<TableRow key={busca.id}>
<TableCell>
<p className="font-medium">{busca.termoBusca}</p>
<p className="text-xs text-muted-foreground">{busca.localizacao}</p>
</TableCell>
<TableCell>
<StatusBadge status={busca.status} />
</TableCell>
<TableCell className="text-right font-medium">{busca.totalEncontrados ?? 0}</TableCell>
<TableCell className="text-right font-medium">{busca.telefonesCapturados ?? 0}</TableCell>
</TableRow>
))}
{(resumoQuery.isError || buscasQuery.isError) && (
<TableRow>
<TableCell colSpan={4} className="text-destructive">
Não foi possível carregar o resumo.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</section>
</div>
);
}
function Metric({
title,
value,
icon: Icon,
}: {
title: string;
value: string | number;
icon: typeof Users;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between p-5">
<div>
<p className="text-sm text-muted-foreground">{title}</p>
<p className="mt-2 text-2xl font-semibold">{value}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary">
<Icon className="h-5 w-5 text-primary" aria-hidden="true" />
</div>
</CardContent>
</Card>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Filter } from "lucide-react";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { api, getAuthToken } from "../lib/api";
import type { StatusLead } from "../types/domain";
const leadStatuses: StatusLead[] = ["novo", "contatado", "qualificado", "convertido", "descartado"];
export function LeadsPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [cidade, setCidade] = useState(searchParams.get("cidade") ?? "");
const params = useMemo(
() => ({
ramoId: searchParams.get("ramoId"),
cidade: searchParams.get("cidade"),
uf: searchParams.get("uf"),
status: searchParams.get("status") as StatusLead | null,
buscaId: searchParams.get("buscaId"),
size: 50,
}),
[searchParams],
);
const leadsQuery = useQuery({ queryKey: ["leads", params], queryFn: () => api.listarLeads(params) });
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
const atualizarStatus = useMutation({
mutationFn: ({ id, status }: { id: number; status: StatusLead }) => api.atualizarStatusLead(id, status),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["leads"] }),
});
function updateParam(key: string, value: string) {
const next = new URLSearchParams(searchParams);
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
setSearchParams(next);
}
async function exportarCsv() {
const response = await fetch(api.exportarLeadsUrl(searchParams), {
headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {},
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "leads.csv";
link.click();
URL.revokeObjectURL(url);
}
const ramos = ramosQuery.data ?? [];
const leads = leadsQuery.data?.content ?? [];
const buscaId = searchParams.get("buscaId");
return (
<div className="space-y-4 pb-16 lg:pb-0">
{buscaId && (
<Card>
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Mostrando leads da busca <span className="font-medium text-foreground">#{buscaId}</span>.
</p>
<Button variant="outline" onClick={() => updateParam("buscaId", "")}>
Ver todos os leads
</Button>
</CardContent>
</Card>
)}
<Card>
<CardContent className="grid gap-3 p-4 md:grid-cols-[1fr_160px_160px_auto]">
<Input placeholder="Cidade" value={cidade} onChange={(event) => setCidade(event.target.value)} />
<Select value={searchParams.get("ramoId") ?? ""} onChange={(event) => updateParam("ramoId", event.target.value)}>
<option value="">Todos os ramos</option>
{ramos.map((ramo) => (
<option key={ramo.id} value={ramo.id}>
{ramo.nomeCanonico}
</option>
))}
</Select>
<Select value={searchParams.get("status") ?? ""} onChange={(event) => updateParam("status", event.target.value)}>
<option value="">Todos os status</option>
{leadStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</Select>
<div className="flex gap-2">
<Button variant="outline" size="icon" aria-label="Aplicar filtros" onClick={() => updateParam("cidade", cidade)}>
<Filter className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="secondary" onClick={exportarCsv}>
<Download className="h-4 w-4" aria-hidden="true" />
CSV
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Leads capturados</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>Cidade</TableHead>
<TableHead>Ramo</TableHead>
<TableHead>Status</TableHead>
<TableHead>Busca</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leads.map((lead) => (
<TableRow key={lead.id}>
<TableCell>
<p className="font-medium">{lead.nomeFantasia}</p>
<p className="text-xs text-muted-foreground">{lead.site ?? lead.enderecoCompleto}</p>
</TableCell>
<TableCell>{lead.telefoneE164 ?? lead.telefoneBruto ?? "-"}</TableCell>
<TableCell>
{lead.cidade}, {lead.uf}
</TableCell>
<TableCell>{lead.ramoId ? ramos.find((ramo) => ramo.id === lead.ramoId)?.nomeCanonico ?? `#${lead.ramoId}` : "Sem ramo"}</TableCell>
<TableCell>
<Select
value={lead.status}
onChange={(event) => atualizarStatus.mutate({ id: lead.id, status: event.target.value as StatusLead })}
>
{leadStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</Select>
</TableCell>
<TableCell>#{lead.buscaId}</TableCell>
</TableRow>
))}
{leadsQuery.isLoading && (
<TableRow>
<TableCell colSpan={6}>Carregando leads...</TableCell>
</TableRow>
)}
{leadsQuery.isError && (
<TableRow>
<TableCell colSpan={6} className="text-destructive">
Não foi possível carregar os leads.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap } from "lucide-react";
import { useForm } from "react-hook-form";
import { Navigate, useNavigate } from "react-router-dom";
import { z } from "zod";
import { useAuth } from "../hooks/useAuth";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
const schema = z.object({
email: z.string().trim().min(1),
senha: z.string().min(1),
});
type LoginForm = z.infer<typeof schema>;
export function LoginPage() {
const { isAuthenticated, login } = useAuth();
const navigate = useNavigate();
const { register, handleSubmit, formState, setError } = useForm<LoginForm>({
resolver: zodResolver(schema),
defaultValues: {
email: "",
senha: "",
},
});
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
const shouldShowValidation = formState.submitCount > 0;
async function onSubmit(values: LoginForm) {
try {
await login(values.email.trim(), values.senha);
navigate("/", { replace: true });
} catch {
setError("root", { message: "Email ou senha inválidos." });
}
}
return (
<main className="grid min-h-screen place-items-center bg-background px-4 py-10">
<Card className="w-full max-w-sm">
<CardHeader>
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground">
<DatabaseZap className="h-5 w-5" aria-hidden="true" />
</div>
<CardTitle>LeadScraper</CardTitle>
<CardDescription>Acesso ao dashboard interno de prospecção.</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" noValidate onSubmit={handleSubmit(onSubmit)}>
<label className="block space-y-1.5">
<span className="text-sm font-medium">Email</span>
<Input type="text" inputMode="email" autoComplete="email" {...register("email")} />
{shouldShowValidation && formState.errors.email && (
<span className="text-xs text-destructive">Email obrigatório</span>
)}
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium">Senha</span>
<Input type="password" autoComplete="current-password" {...register("senha")} />
{shouldShowValidation && formState.errors.senha && (
<span className="text-xs text-destructive">Senha obrigatória</span>
)}
</label>
<Button className="w-full" type="submit">
{formState.isSubmitting ? "Entrando..." : "Entrar"}
</Button>
{formState.errors.root && <p className="text-sm text-destructive">{formState.errors.root.message}</p>}
</form>
</CardContent>
</Card>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Play } from "lucide-react";
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/select";
import { api } from "../lib/api";
export function NovaBuscaPage() {
const navigate = useNavigate();
const [formError, setFormError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(true);
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
const criarBusca = useMutation({
mutationFn: api.criarBusca,
onSuccess: () => navigate("/buscas"),
});
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const termoBusca = String(formData.get("termoBusca") ?? "").trim();
const localizacao = String(formData.get("localizacao") ?? "").trim();
const ramoId = String(formData.get("ramoId") ?? "");
if (!termoBusca || !localizacao) {
setFormError("Preencha o termo de busca e a localização.");
return;
}
setFormError(null);
criarBusca.mutate({
termoBusca,
localizacao,
ramoId: ramoId ? Number(ramoId) : null,
expandirPorBairros: expanded,
});
}
return (
<div className="max-w-3xl pb-16 lg:pb-0">
<Card>
<CardHeader>
<CardTitle>Disparar busca</CardTitle>
<CardDescription>A busca entra como pendente e será processada pelo worker.</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
<label className="block space-y-1.5 md:col-span-2">
<span className="text-sm font-medium">Termo de busca</span>
<Input
name="termoBusca"
defaultValue="restaurantes japoneses"
placeholder="Ex.: pizzaria, clínica odontológica, oficina mecânica"
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium">Localização</span>
<Input name="localizacao" defaultValue="Campinas, SP" placeholder="Ex.: Campinas, SP" />
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium">Ramo</span>
<Select name="ramoId" defaultValue="">
<option value="">Sem ramo</option>
{(ramosQuery.data ?? []).map((ramo) => (
<option key={ramo.id} value={ramo.id}>
{ramo.nomeCanonico}
</option>
))}
</Select>
</label>
<label className="flex items-start gap-3 rounded-md border p-3 md:col-span-2">
<input
type="checkbox"
checked={expanded}
onChange={(event) => setExpanded(event.target.checked)}
className="mt-1 h-4 w-4"
/>
<span className="text-sm">
<span className="block font-medium">Expandir por bairros quando disponível</span>
<span className="text-muted-foreground">
Para cidades grandes, o sistema pesquisa regiões em segundo plano e consolida tudo nesta busca.
</span>
</span>
</label>
<div className="md:col-span-2">
<Button type="submit" disabled={criarBusca.isPending}>
<Play className="h-4 w-4" aria-hidden="true" />
{criarBusca.isPending ? "Criando..." : "Criar busca"}
</Button>
{formError && <p className="mt-2 text-sm text-destructive">{formError}</p>}
{criarBusca.isError && (
<p className="mt-2 text-sm text-destructive">
Não foi possível criar a busca. Atualize a página e tente novamente.
</p>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
export type StatusBusca = "pendente" | "em_execucao" | "concluida" | "erro";
export type StatusLead = "novo" | "contatado" | "qualificado" | "convertido" | "descartado";
export interface Busca {
id: number;
termoBusca: string;
localizacao: string;
ramoId: number | null;
status: StatusBusca;
totalEncontrados: number | null;
telefonesCapturados: number;
regioesTotal: number | null;
regioesSucesso: number | null;
regioesErro: number | null;
detalheRegioes: string | null;
detalheErro: string | null;
iniciadoEm: string | null;
finalizadoEm: string | null;
criadoEm: string;
}
export interface Lead {
id: number;
nomeFantasia: string;
telefoneE164: string;
telefoneBruto: string | null;
enderecoCompleto: string | null;
cidade: string;
uf: string;
ramoId: number | null;
categoriaBruta: string;
status: StatusLead;
buscaId: number;
site: string | null;
criadoEm: string;
}
export interface RamoResumo {
ramo: string;
total: number;
}
export interface Ramo {
id: number;
nomeCanonico: string;
aliases: string[];
}
export interface Page<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
export interface DashboardResumo {
totalLeads: number;
telefonesCapturados: number;
telefonesUnicos: number;
buscasEmExecucao: number;
aguardandoCuradoria: number;
convertidos: number;
leadsPorRamo: RamoResumo[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />