Modulo Commander CSV

This commit is contained in:
Vitex Tecnologia
2026-05-28 11:06:46 -03:00
parent 422107331b
commit 34ae317fed
4 changed files with 500 additions and 0 deletions
@@ -0,0 +1,449 @@
import { useMemo, useState, type ChangeEvent } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Copy, Download, FileText, Loader2, Plus, Upload, X } from "lucide-react";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { toast } from "sonner";
import { commanderCsvService, type CommanderCsvMode } from "@/services/commanderCsv";
type HeaderItem = {
original: string;
slug: string;
};
const toSlug = (value: string) =>
value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
export function CommanderCsvView() {
const [mode, setMode] = useState<CommanderCsvMode>("pdf");
const [headerInput, setHeaderInput] = useState("");
const [headers, setHeaders] = useState<HeaderItem[]>([]);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [content, setContent] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHealthLoading, setIsHealthLoading] = useState(false);
const [healthStatus, setHealthStatus] = useState<"idle" | "ok" | "error">("idle");
const [responseData, setResponseData] = useState<unknown>(null);
const [isClearHeadersOpen, setIsClearHeadersOpen] = useState(false);
const [lastSentAt, setLastSentAt] = useState<Date | null>(null);
const [lastSentMode, setLastSentMode] = useState<CommanderCsvMode | null>(null);
const headersCsv = useMemo(() => headers.map((header) => header.slug).join(","), [headers]);
const addHeader = () => {
const original = headerInput.trim();
if (!original) return;
const slug = toSlug(original);
if (!slug) {
toast.error("Cabecalho invalido. Digite letras ou numeros.");
return;
}
if (headers.some((header) => header.slug === slug)) {
toast.error("Esse cabecalho ja foi adicionado.");
return;
}
setHeaders((prev) => [...prev, { original, slug }]);
setHeaderInput("");
};
const removeHeader = (slug: string) => {
setHeaders((prev) => prev.filter((header) => header.slug !== slug));
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
const fileName = file.name.toLowerCase();
if (!fileName.endsWith(".pdf")) {
toast.error("Envie apenas arquivo PDF.");
return;
}
setSelectedFile(file);
};
const runHealthcheck = async () => {
setIsHealthLoading(true);
try {
await commanderCsvService.healthcheck();
setHealthStatus("ok");
toast.success("API online.");
} catch (error: unknown) {
setHealthStatus("error");
const message = error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Falha no healthcheck.";
toast.error(message);
} finally {
setIsHealthLoading(false);
}
};
const submit = async () => {
if (headers.length === 0) {
toast.error("Adicione pelo menos um cabecalho.");
return;
}
if (mode === "pdf" && !selectedFile) {
toast.error("Selecione um PDF para enviar.");
return;
}
if (mode === "text" && !content.trim()) {
toast.error("Preencha o conteudo de texto.");
return;
}
setIsSubmitting(true);
try {
const result = mode === "pdf"
? await commanderCsvService.pdf2csv(selectedFile as File, headersCsv)
: await commanderCsvService.txt2csv(content.trim(), headersCsv);
setResponseData(result);
setLastSentAt(new Date());
setLastSentMode(mode);
toast.success("Processamento concluido.");
} catch (error: unknown) {
const message = error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Erro ao enviar dados para API.";
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const responseContent = useMemo(() => {
if (responseData && typeof responseData === "object" && "content" in responseData) {
const content = (responseData as { content?: unknown }).content;
if (typeof content === "string") return content;
}
return null;
}, [responseData]);
const copyResponse = async () => {
const textToCopy = responseContent ?? (responseData ? JSON.stringify(responseData, null, 2) : "");
if (!textToCopy) {
toast.error("Nao ha resposta para copiar.");
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
toast.success("Resposta copiada.");
} catch {
toast.error("Falha ao copiar resposta.");
}
};
const downloadCsv = () => {
if (!responseContent) {
toast.error("Nao ha CSV para baixar.");
return;
}
const utf8Bom = "\uFEFF";
const blob = new Blob([utf8Bom, responseContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `commander-csv-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.csv`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
toast.success("Download do CSV iniciado.");
};
const sendBlockReason = useMemo(() => {
if (headers.length === 0) return "Adicione pelo menos um cabecalho.";
if (mode === "pdf" && !selectedFile) return "Selecione um PDF para envio.";
if (mode === "text" && !content.trim()) return "Preencha o conteudo de texto.";
return null;
}, [headers.length, mode, selectedFile, content]);
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-xl md:text-3xl font-bold">Commander CSV</h1>
<p className="text-sm text-muted-foreground">
Configure os cabecalhos, escolha o modo de envio e gere o CSV com mais controle.
</p>
</div>
<div className="flex items-center gap-2">
<div
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold ${
healthStatus === "ok"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: healthStatus === "error"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-border bg-muted/40 text-muted-foreground"
}`}
>
<span
className={`h-2 w-2 rounded-full ${
healthStatus === "ok"
? "bg-emerald-500 animate-online-pulse"
: healthStatus === "error"
? "bg-destructive"
: "bg-muted-foreground/70"
}`}
/>
{healthStatus === "ok" ? "API online" : healthStatus === "error" ? "API indisponivel" : "Status da API"}
</div>
<Button variant="outline" onClick={runHealthcheck} disabled={isHealthLoading}>
{isHealthLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Healthcheck
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
<div className="mx-auto w-full max-w-6xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Cabecalhos</CardTitle>
<CardDescription>
Defina os campos do CSV. O sistema gera slug automaticamente em minusculo com underline.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Input
value={headerInput}
onChange={(event) => setHeaderInput(event.target.value)}
placeholder="Ex: Data de Aniversario"
className="min-w-[220px] flex-1"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addHeader();
}
}}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsClearHeadersOpen(true)}
disabled={headers.length === 0}
>
Limpar todos
</Button>
<Button type="button" onClick={addHeader} disabled={!headerInput.trim()}>
<Plus className="w-4 h-4 mr-1" />
Adicionar
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-3">
{headers.length === 0 ? (
<p className="text-sm text-muted-foreground">
Adicione seu primeiro cabecalho para habilitar o envio.
</p>
) : (
headers.map((header) => (
<div
key={header.slug}
className="group inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1.5 shadow-sm transition-colors hover:border-primary/40 hover:bg-primary/10"
>
<p className="text-xs font-semibold text-foreground">{header.slug}</p>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeHeader(header.slug)}
title="Remover cabecalho"
className="h-5 w-5 rounded-full text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Envio</CardTitle>
<CardDescription>
Alterne entre PDF ou Texto com os mesmos cabecalhos configurados.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as CommanderCsvMode)}
className="grid gap-3 sm:grid-cols-2"
>
<label className="flex cursor-pointer items-center gap-3 rounded-md border p-4">
<RadioGroupItem value="pdf" id="csv-mode-pdf" />
<div>
<p className="text-sm font-medium">Modo PDF</p>
<p className="text-xs text-muted-foreground">Upload de arquivo PDF + headers</p>
</div>
</label>
<label className="flex cursor-pointer items-center gap-3 rounded-md border p-4">
<RadioGroupItem value="text" id="csv-mode-text" />
<div>
<p className="text-sm font-medium">Modo Texto</p>
<p className="text-xs text-muted-foreground">Conteudo em texto + headers</p>
</div>
</label>
</RadioGroup>
{mode === "pdf" ? (
<div className="space-y-3">
<Label htmlFor="csv-pdf-input">Arquivo PDF</Label>
<input
id="csv-pdf-input"
type="file"
accept=".pdf,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
<label
htmlFor="csv-pdf-input"
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors hover:border-primary/50"
>
<Upload className="h-7 w-7 text-primary" />
<div>
<p className="text-sm font-medium">
{selectedFile ? selectedFile.name : "Clique para anexar um PDF"}
</p>
<p className="text-xs text-muted-foreground">
{selectedFile ? `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB` : "Somente .pdf"}
</p>
</div>
</label>
</div>
) : (
<div className="space-y-3">
<Label htmlFor="csv-text-input">Conteudo para converter</Label>
<Textarea
id="csv-text-input"
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="Cole aqui os dados em texto que serao convertidos para CSV..."
rows={10}
className="resize-y min-h-[220px]"
/>
</div>
)}
<div className="sticky bottom-0 z-10 -mx-6 rounded-b-lg border-t bg-card/95 px-6 py-4 backdrop-blur">
{isSubmitting ? (
<div className="mb-3 flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-primary">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processando arquivo e gerando CSV...
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{sendBlockReason ?? "Tudo certo. Pronto para enviar."}
</p>
<InteractiveHoverButton
type="button"
onClick={submit}
disabled={isSubmitting || Boolean(sendBlockReason)}
className="min-w-44 disabled:opacity-50 disabled:pointer-events-none"
>
{isSubmitting ? "Enviando..." : "Enviar para API"}
</InteractiveHoverButton>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="w-5 h-5" />
Resposta da API
</CardTitle>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={copyResponse}>
<Copy className="w-4 h-4 mr-2" />
Copiar
</Button>
<Button type="button" variant="outline" size="sm" onClick={downloadCsv} disabled={!responseContent}>
<Download className="w-4 h-4 mr-2" />
Baixar CSV
</Button>
</div>
</div>
{lastSentAt ? (
<p className="text-xs text-muted-foreground">
Ultimo envio: {lastSentAt.toLocaleTimeString("pt-BR")} ({lastSentMode === "pdf" ? "PDF" : "Texto"})
</p>
) : null}
</CardHeader>
<CardContent>
<Textarea
value={responseContent ?? (responseData ? JSON.stringify(responseData, null, 2) : "Nenhuma resposta ainda.")}
readOnly
className="min-h-[62vh] font-mono text-xs leading-relaxed resize-y"
/>
</CardContent>
</Card>
</div>
</div>
<AlertDialog open={isClearHeadersOpen} onOpenChange={setIsClearHeadersOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Limpar todos os cabecalhos?</AlertDialogTitle>
<AlertDialogDescription>
Essa acao remove todos os cabecalhos adicionados nesta tela.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setHeaders([]);
setIsClearHeadersOpen(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Limpar tudo
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+2
View File
@@ -13,6 +13,7 @@ import { AreasView } from "@/components/areas/AreasView";
import { ParecerJuridicoView } from "@/components/parecer-juridico/ParecerJuridicoView"; import { ParecerJuridicoView } from "@/components/parecer-juridico/ParecerJuridicoView";
import { ParecerJuridicoFormView } from "@/components/parecer-juridico/ParecerJuridicoFormView"; import { ParecerJuridicoFormView } from "@/components/parecer-juridico/ParecerJuridicoFormView";
import { ParecerJuridicoDetailView } from "@/components/parecer-juridico/ParecerJuridicoDetailView"; import { ParecerJuridicoDetailView } from "@/components/parecer-juridico/ParecerJuridicoDetailView";
import { CommanderCsvView } from "@/components/commander-csv/CommanderCsvView";
const CommanderApp = () => { const CommanderApp = () => {
React.useEffect(() => { React.useEffect(() => {
@@ -37,6 +38,7 @@ const CommanderApp = () => {
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} /> <Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} /> <Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
<Route path="areas" element={<AreasView />} /> <Route path="areas" element={<AreasView />} />
<Route path="commander-csv" element={<CommanderCsvView />} />
<Route path="*" element={<Navigate to="bate-papo" replace />} /> <Route path="*" element={<Navigate to="bate-papo" replace />} />
</Routes> </Routes>
</MainLayout> </MainLayout>
@@ -11,6 +11,7 @@ import {
Bot, Bot,
FileText, FileText,
Layers, Layers,
FileSpreadsheet,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -43,6 +44,12 @@ const navSections: { title: string; items: NavItem[] }[] = [
{ path: "prompts", label: "Prompts", icon: FileText }, { path: "prompts", label: "Prompts", icon: FileText },
], ],
}, },
{
title: "Personalizado",
items: [
{ path: "commander-csv", label: "Commander CSV", icon: FileSpreadsheet },
],
},
]; ];
const navItems = navSections.flatMap((section) => section.items); const navItems = navSections.flatMap((section) => section.items);
+42
View File
@@ -0,0 +1,42 @@
import axios from "axios";
const COMMANDER_CSV_BASE_URL = "https://hmg-hgtx-n8n.hgtx.com.br/webhook/hgtxialocal";
const commanderCsvApi = axios.create({
baseURL: COMMANDER_CSV_BASE_URL,
timeout: 600_000,
headers: {
"Content-Type": "application/json",
},
});
export type CommanderCsvMode = "pdf" | "text";
export const commanderCsvService = {
async healthcheck() {
const response = await commanderCsvApi.get("/healthcheck");
return response.data;
},
async pdf2csv(file: File, headersCsv: string) {
const formData = new FormData();
formData.append("file", file);
formData.append("headers", headersCsv);
const response = await commanderCsvApi.post("/pdf2csv", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data;
},
async txt2csv(content: string, headersCsv: string) {
const response = await commanderCsvApi.post("/txt2csv", {
content,
headers: headersCsv,
});
return response.data;
},
};