Initial commit
This commit is contained in:
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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"];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user