Implementa o MVP do Adaptador de Planilhas no Elo Pro.
O módulo usa a identidade Supabase, isola cada workspace e registra storage, TTL, runner e biblioteca XLSX como decisões ainda abertas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
6e23f1533b
144 files changed
+19060
No files matched your search
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
|
||||
import type { ModelDto } from "@/server/spreadsheet-adapter/dto";
|
||||
import { Button, Card, SemanticMarker } from "./ui";
|
||||
import { EmptyState, ErrorState, LoadingBlock } from "./states";
|
||||
|
||||
export function Dashboard({ ctx }: { ctx: RequestContext }) {
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.models(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/models");
|
||||
const data = (await response.json()) as { models?: ModelDto[]; error?: { message: string } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar modelos");
|
||||
return data.models ?? [];
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Adaptador de Planilhas</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-ink/80">
|
||||
Receba planilhas de origens diferentes, valide a estrutura, mapeie as colunas para um modelo e gere um arquivo novo sem alterar o modelo original.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/adaptador/operacao">
|
||||
<Button>Iniciar adaptação</Button>
|
||||
</Link>
|
||||
</header>
|
||||
{models.isLoading ? <LoadingBlock label="Carregando modelos" /> : null}
|
||||
{models.isError ? <ErrorState message={models.error.message} onRetry={() => models.refetch()} /> : null}
|
||||
{models.data && models.data.length === 0 ? (
|
||||
<EmptyState title="Nenhum modelo ativo" description="Cadastre um modelo XLSX ou CSV para começar uma adaptação." action={<Link href="/adaptador/configuracoes"><Button variant="secondary">Gerir modelos</Button></Link>} />
|
||||
) : null}
|
||||
{models.data && models.data.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{models.data.map((model) => (
|
||||
<Card key={model.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{model.name}</h2>
|
||||
<p className="text-sm text-ink/70">{model.columns.length} colunas · {model.format.toUpperCase()} · {new Date(model.createdAt).toLocaleDateString("pt-BR")} · {model.createdByName}</p>
|
||||
</div>
|
||||
<SemanticMarker tone="info">{model.columns.filter((column) => column.required).length} obrigatórias</SemanticMarker>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui";
|
||||
|
||||
export function DevLogin({ identities }: { identities: { userId: string; workspaceId: string; label: string }[] }) {
|
||||
const router = useRouter();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="mt-6 space-y-2">
|
||||
<p className="text-sm">Ambiente local: escolha uma identidade já vinculada a um workspace. Não há campo de senha.</p>
|
||||
{identities.map((identity) => (
|
||||
<Button
|
||||
key={identity.userId + identity.workspaceId}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const response = await fetch("/api/dev/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: identity.userId, workspaceId: identity.workspaceId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setMessage("Não foi possível abrir a sessão local.");
|
||||
return;
|
||||
}
|
||||
router.push("/adaptador");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
{identity.label}
|
||||
</Button>
|
||||
))}
|
||||
{message ? <p role="alert">{message}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { DndContext, type DragEndEvent, useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
|
||||
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
||||
import type { PreviewContract } from "@/domain/spreadsheet-adapter/preview";
|
||||
import type { JobDto, ModelDto } from "@/server/spreadsheet-adapter/dto";
|
||||
import { Button, Card, Dialog, JourneyTrail, Select, SemanticMarker, TextInput } from "./ui";
|
||||
import { EmptyState, ErrorState, LoadingBlock } from "./states";
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/* eslint-disable react-hooks/refs -- setNodeRef e listeners são a API de render do @dnd-kit */
|
||||
function SourceChip({ name }: { name: string }) {
|
||||
const drag = useDraggable({ id: `source:${name}` });
|
||||
return (
|
||||
<button type="button" ref={drag.setNodeRef} {...drag.listeners} {...drag.attributes} className="rounded-full border border-line bg-cloud px-3 py-1 text-sm">
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDrop({ name, required, value, options, onChange }: { name: string; required: boolean; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
const drop = useDroppable({ id: `model:${name}` });
|
||||
return (
|
||||
<div ref={drop.setNodeRef} className={`rounded-[14px] border p-3 ${drop.isOver ? "border-bright" : "border-line"}`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{name}</span>
|
||||
{required ? <SemanticMarker tone="warning">Obrigatória</SemanticMarker> : <span className="text-xs">Opcional</span>}
|
||||
</div>
|
||||
<label className="text-xs" htmlFor={`map-${name}`}>Coluna da fonte para {name}</label>
|
||||
<Select id={`map-${name}`} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">Não mapear</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* eslint-enable react-hooks/refs */
|
||||
|
||||
export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
const client = useQueryClient();
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [criteria, setCriteria] = useState(false);
|
||||
const [sheetDialog, setSheetDialog] = useState<{ sourceId: string; sheets: string[] } | null>(null);
|
||||
const [mappings, setMappings] = useState<{ sourceColumn: string; modelColumn: string }[]>([]);
|
||||
const [preview, setPreview] = useState<PreviewContract | null>(null);
|
||||
const [activeSource, setActiveSource] = useState<string | null>(null);
|
||||
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.models(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/models");
|
||||
const data = await readJson<{ models: ModelDto[]; error?: { message: string } }>(response);
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar modelos");
|
||||
return data.models;
|
||||
},
|
||||
});
|
||||
const job = useQuery({
|
||||
queryKey: queryKeys.job(ctx.workspaceId, jobId ?? "none"),
|
||||
enabled: Boolean(jobId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "queued" || status === "processing" ? 1000 : false;
|
||||
},
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}`);
|
||||
const data = await readJson<{ job: JobDto; error?: { message: string } }>(response);
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao consultar a operação");
|
||||
return data.job;
|
||||
},
|
||||
});
|
||||
|
||||
const source = job.data?.sources.find((item) => item.id === activeSource) ?? job.data?.sources.find((item) => item.status !== "completed");
|
||||
const validation = useMemo(() => {
|
||||
if (!job.data || !source) return null;
|
||||
return validateMappings(job.data.model.columns, source.columns.map((column) => column.name), mappings);
|
||||
}, [job.data, source, mappings]);
|
||||
|
||||
function assign(modelColumn: string, sourceColumn: string) {
|
||||
setMappings((current) => {
|
||||
const next = current.filter((item) => item.modelColumn !== modelColumn && item.sourceColumn !== sourceColumn);
|
||||
if (sourceColumn) next.push({ sourceColumn, modelColumn });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function start(modelId: string) {
|
||||
setMessage(null);
|
||||
const response = await fetch("/api/spreadsheet-adapter/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelId, idempotencyKey: crypto.randomUUID() }),
|
||||
});
|
||||
const data = await readJson<{ job?: JobDto; error?: { message: string } }>(response);
|
||||
if (!response.ok || !data.job) {
|
||||
setMessage(data.error?.message ?? "Não foi possível abrir a operação");
|
||||
return;
|
||||
}
|
||||
setJobId(data.job.id);
|
||||
setStep(1);
|
||||
await client.invalidateQueries({ queryKey: queryKeys.job(ctx.workspaceId, data.job.id) });
|
||||
}
|
||||
|
||||
async function upload(file: File) {
|
||||
if (!jobId) return;
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("jobId", jobId);
|
||||
const response = await fetch("/api/spreadsheet-adapter/sources/validate", { method: "POST", body: form });
|
||||
const data = await readJson<{ job: JobDto; code?: string; message?: string; error?: { message: string } }>(response);
|
||||
if (data.error) {
|
||||
setMessage(data.error.message);
|
||||
return;
|
||||
}
|
||||
setMessage(data.message ?? null);
|
||||
const created = data.job.sources.at(-1);
|
||||
if (created && data.code === "SHEET_REQUIRED") setSheetDialog({ sourceId: created.id, sheets: created.sheets });
|
||||
await job.refetch();
|
||||
}
|
||||
|
||||
const saveMap = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jobId || !source) return;
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: source.id, mappings }),
|
||||
});
|
||||
const data = await readJson<{ error?: { message: string } }>(response);
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Mapeamento rejeitado");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: source?.id }),
|
||||
});
|
||||
const data = await readJson<PreviewContract & { error?: { message: string } }>(response);
|
||||
if (!response.ok) {
|
||||
setMessage(data.error?.message ?? "Prévia indisponível");
|
||||
return;
|
||||
}
|
||||
setPreview(data);
|
||||
setStep(3);
|
||||
},
|
||||
});
|
||||
|
||||
async function processCurrent() {
|
||||
if (!jobId || !source) return;
|
||||
setStep(4);
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/process`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: source.id, idempotencyKey: crypto.randomUUID() }),
|
||||
});
|
||||
const data = await readJson<{ error?: { message: string } }>(response);
|
||||
if (!response.ok) setMessage(data.error?.message ?? "Processamento não iniciado");
|
||||
await job.refetch();
|
||||
setStep(5);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold">Nova adaptação</h1>
|
||||
<p className="mt-2 text-sm text-ink/75">Uma transferência por vez. A prévia é obrigatória antes de gerar o arquivo.</p>
|
||||
</header>
|
||||
<JourneyTrail steps={["Modelo", "Fontes", "Mapeamento", "Revisão", "Processamento", "Resultado"]} current={step} />
|
||||
{message ? <ErrorState message={message} /> : null}
|
||||
{step === 0 ? (
|
||||
<Card>
|
||||
{models.isLoading ? <LoadingBlock /> : null}
|
||||
{models.isError ? <ErrorState message={models.error.message} onRetry={() => models.refetch()} /> : null}
|
||||
{models.data?.length === 0 ? <EmptyState title="Sem modelos" description="Peça a um administrador para cadastrar um modelo ou habilite o upload operacional." /> : null}
|
||||
<div className="grid gap-3">
|
||||
{models.data?.map((model) => (
|
||||
<div key={model.id} className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold">{model.name}</p>
|
||||
<p className="text-sm text-ink/70">{model.columns.map((column) => column.name).join(", ")}</p>
|
||||
</div>
|
||||
<Button onClick={() => start(model.id)}>Usar este modelo</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{step === 1 && job.data ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Fontes de {job.data.model.name}</h2>
|
||||
<Button variant="ghost" onClick={() => setCriteria(true)}>Ver critérios para upload</Button>
|
||||
</div>
|
||||
<label className="block text-sm font-medium" htmlFor="fontes">Enviar XLSX ou CSV</label>
|
||||
<input id="fontes" className="mt-2 block w-full" type="file" accept=".xlsx,.csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" multiple onChange={(event) => {
|
||||
const files = [...(event.target.files ?? [])];
|
||||
files.forEach((file) => void upload(file));
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<ul className="mt-4 space-y-3">
|
||||
{job.data.sources.map((item) => (
|
||||
<li key={item.id} className="rounded-[14px] border border-line p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong>{item.sourceName}</strong>
|
||||
<SemanticMarker tone={item.status === "invalid" || item.status === "pending" ? "error" : "success"}>{item.status}</SemanticMarker>
|
||||
</div>
|
||||
<label className="mt-2 block text-xs" htmlFor={`header-${item.id}`}>Linha do cabeçalho</label>
|
||||
<TextInput id={`header-${item.id}`} type="number" min={1} defaultValue={item.headerRow} onBlur={(event) => {
|
||||
void fetch(`/api/spreadsheet-adapter/jobs/${job.data?.id}/sources/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headerRow: Number(event.target.value), sheet: item.selectedSheet }),
|
||||
}).then(() => job.refetch());
|
||||
}} />
|
||||
<Button variant="ghost" onClick={() => fetch(`/api/spreadsheet-adapter/jobs/${job.data?.id}/sources/${item.id}`, { method: "DELETE" }).then(() => job.refetch())}>Remover</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button className="mt-4" disabled={!job.data.sources.some((item) => item.status === "validated" || item.status === "mapped")} onClick={() => {
|
||||
const next = job.data?.sources.find((item) => item.status === "validated" || item.status === "mapped");
|
||||
if (!next) return;
|
||||
setActiveSource(next.id);
|
||||
setMappings(next.mappings.map((item) => ({ sourceColumn: item.sourceColumn, modelColumn: item.modelColumn })));
|
||||
setStep(2);
|
||||
}}>Mapear fonte</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
{step === 2 && job.data && source ? (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-lg font-semibold">Mapear {source.sourceName}</h2>
|
||||
<p className="mb-4 text-sm">Arraste uma coluna ou use o select. As duas formas gravam o mesmo mapeamento 1:1.</p>
|
||||
<DndContext onDragEnd={(event: DragEndEvent) => {
|
||||
const sourceName = String(event.active.id).replace("source:", "");
|
||||
const modelName = String(event.over?.id ?? "").replace("model:", "");
|
||||
if (modelName) assign(modelName, sourceName);
|
||||
}}>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{source.columns.map((column) => <SourceChip key={column.name} name={column.name} />)}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{job.data.model.columns.map((column) => (
|
||||
<ModelDrop key={column.name} name={column.name} required={column.required} value={mappings.find((item) => item.modelColumn === column.name)?.sourceColumn ?? ""} options={source.columns.map((item) => item.name)} onChange={(value) => assign(column.name, value)} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
{validation && !validation.ok ? <p className="mt-3 text-sm" role="status">{validation.missingRequired.length ? `Faltam obrigatórias: ${validation.missingRequired.join(", ")}.` : validation.errors[0]}</p> : <SemanticMarker tone="success">Mapeamento pronto para revisão</SemanticMarker>}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setStep(1)}>Voltar</Button>
|
||||
<Button disabled={!validation?.ok || saveMap.isPending} loading={saveMap.isPending} onClick={() => saveMap.mutate()}>Revisar prévia</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{step === 3 && preview ? (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">Prévia do resultado</h2>
|
||||
<p className="mt-1 text-sm">Isto ainda não grava a saída. O modelo original permanece intacto.</p>
|
||||
<div className="mt-4 overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>{preview.columns.map((column) => <th key={column.name} className="border-b border-line p-2">{column.name}{column.required ? " (obrigatória)" : ""}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.rows.map((row, index) => <tr key={index}>{row.map((cell, cellIndex) => <td key={cellIndex} className="border-b border-line p-2">{cell}</td>)}</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setStep(2)}>Voltar ao mapeamento</Button>
|
||||
<Button onClick={() => void processCurrent()}>Processar</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{(step === 4 || step === 5) && job.data ? (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{job.data.progress?.label ?? "Processando"}</h2>
|
||||
<p className="mt-2 text-sm">{job.data.progress?.percent == null ? "Progresso ainda não é estimável." : `${job.data.progress.percent}%`}</p>
|
||||
{job.data.status === "failed" ? <ErrorState message={job.data.errorCode ?? "Falha no processamento"} onRetry={() => void processCurrent()} /> : null}
|
||||
{source?.hasOutput ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(["xlsx", "csv", "pdf"] as const).map((format) => (
|
||||
<a key={format} className="inline-flex min-h-11 items-center rounded-[18px] bg-action px-4 text-sm font-semibold text-white" href={`/api/spreadsheet-adapter/jobs/${job.data?.id}/download?format=${format}&sourceId=${source.id}`}>{format.toUpperCase()}</a>
|
||||
))}
|
||||
<Button variant="secondary" onClick={() => { setPreview(null); setStep(1); }}>Continuar com outra fonte</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{job.data.status === "completed" ? <div className="mt-4"><SemanticMarker tone="success">Adaptação concluída</SemanticMarker></div> : null}
|
||||
</Card>
|
||||
) : null}
|
||||
<Dialog open={criteria} title="Critérios para upload" onClose={() => setCriteria(false)}>
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm">
|
||||
<li>Formatos XLSX e CSV. XLS, ODS e XML ficam de fora.</li>
|
||||
<li>Até 10 MB e 50.000 linhas úteis por arquivo.</li>
|
||||
<li>Até 10 fontes nesta operação e 10 modelos ativos no workspace.</li>
|
||||
<li>Em XLSX com várias abas, escolha exatamente uma.</li>
|
||||
<li>Toda coluna usada precisa de cabeçalho. A linha pode ser ajustada.</li>
|
||||
</ul>
|
||||
<Button className="mt-4" onClick={() => setCriteria(false)}>Fechar</Button>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(sheetDialog)} title="Selecione uma aba" onClose={() => setSheetDialog(null)}>
|
||||
<div className="space-y-2">
|
||||
{sheetDialog?.sheets.map((sheet) => (
|
||||
<Button key={sheet} variant="secondary" onClick={() => {
|
||||
void fetch(`/api/spreadsheet-adapter/jobs/${jobId}/sources/${sheetDialog.sourceId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheet }),
|
||||
}).then(() => {
|
||||
setSheetDialog(null);
|
||||
void job.refetch();
|
||||
});
|
||||
}}>{sheet}</Button>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } }));
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
|
||||
import type { ModelDto } from "@/server/spreadsheet-adapter/dto";
|
||||
import { Button, Card, Dialog, Switch } from "./ui";
|
||||
import { EmptyState, ErrorState, LoadingBlock } from "./states";
|
||||
|
||||
export function SettingsPanel({ ctx }: { ctx: RequestContext }) {
|
||||
const client = useQueryClient();
|
||||
const [criteria, setCriteria] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<ModelDto | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [sheet, setSheet] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [required, setRequired] = useState<string[]>([]);
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.models(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/models");
|
||||
const data = (await response.json()) as { models: ModelDto[]; error?: { message: string } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar");
|
||||
return data.models;
|
||||
},
|
||||
});
|
||||
const settings = useQuery({
|
||||
queryKey: queryKeys.settings(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/settings");
|
||||
const data = (await response.json()) as { allowNonAdminModelUpload: boolean; error?: { message: string } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao ler configuração");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!file || !name.trim()) throw new Error("Informe o nome e o arquivo do modelo.");
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("name", name.trim());
|
||||
if (sheet.trim()) form.set("sheet", sheet.trim());
|
||||
form.set("requiredColumns", JSON.stringify(required));
|
||||
const response = await fetch("/api/spreadsheet-adapter/models", { method: "POST", body: form });
|
||||
const data = (await response.json()) as { error?: { message: string; details?: { sheets?: string[] } } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Upload rejeitado");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMessage(null);
|
||||
setFile(null);
|
||||
setName("");
|
||||
await client.invalidateQueries({ queryKey: queryKeys.models(ctx.workspaceId) });
|
||||
},
|
||||
onError: (error: Error) => setMessage(error.message),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold">Configurações</h1>
|
||||
<p className="mt-2 text-sm text-ink/75">Somente owner e admin gerenciam modelos e a permissão de upload.</p>
|
||||
</header>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold">Upload de modelo por gestor e agent</h2>
|
||||
<p className="text-sm text-ink/70">A opção não concede exclusão nem outras configurações.</p>
|
||||
</div>
|
||||
<Switch
|
||||
label="Permitir upload de modelo por perfis operacionais"
|
||||
checked={Boolean(settings.data?.allowNonAdminModelUpload)}
|
||||
disabled={settings.isLoading}
|
||||
onCheckedChange={async (value) => {
|
||||
await fetch("/api/spreadsheet-adapter/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowNonAdminModelUpload: value }),
|
||||
});
|
||||
await settings.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="mb-3 flex justify-between">
|
||||
<h2 className="font-semibold">Novo modelo</h2>
|
||||
<Button variant="ghost" onClick={() => setCriteria(true)}>Ver critérios para upload</Button>
|
||||
</div>
|
||||
<label className="text-sm" htmlFor="model-name">Nome lógico</label>
|
||||
<input id="model-name" className="mt-1 h-11 w-full rounded-[10px] border border-line px-3" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<label className="mt-3 block text-sm" htmlFor="model-sheet">Aba, se o XLSX tiver mais de uma</label>
|
||||
<input id="model-sheet" className="mt-1 h-11 w-full rounded-[10px] border border-line px-3" value={sheet} onChange={(event) => setSheet(event.target.value)} />
|
||||
<label className="mt-3 block text-sm" htmlFor="model-file">Arquivo</label>
|
||||
<input id="model-file" className="mt-1 block" type="file" accept=".xlsx,.csv" onChange={async (event) => {
|
||||
const next = event.target.files?.[0] ?? null;
|
||||
setFile(next);
|
||||
if (!next || !next.name.toLowerCase().endsWith(".csv")) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
const text = await next.text();
|
||||
const header = text.split(/\r?\n/)[0]?.split(/[;,]/).map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
setColumns(header);
|
||||
setRequired(header);
|
||||
}} />
|
||||
{columns.length > 0 ? (
|
||||
<fieldset className="mt-3">
|
||||
<legend className="text-sm font-medium">Colunas obrigatórias</legend>
|
||||
{columns.map((column) => (
|
||||
<label key={column} className="mt-1 flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={required.includes(column)} onChange={(event) => setRequired((current) => event.target.checked ? [...current, column] : current.filter((item) => item !== column))} />
|
||||
{column}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
{message ? <div className="mt-3"><ErrorState message={message} /></div> : null}
|
||||
<Button className="mt-4" loading={upload.isPending} disabled={!ctx.moduleEnabled} onClick={() => upload.mutate()}>Cadastrar modelo</Button>
|
||||
</Card>
|
||||
{models.isLoading ? <LoadingBlock /> : null}
|
||||
{models.data?.length === 0 ? <EmptyState title="Nenhum modelo" description="Os modelos ativos deste workspace aparecem aqui, em ordem alfabética." /> : null}
|
||||
<div className="grid gap-3">
|
||||
{models.data?.map((model) => (
|
||||
<Card key={model.id}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{model.name}</h3>
|
||||
<p className="text-sm text-ink/70">{model.originalFilename} · {model.createdByName}</p>
|
||||
</div>
|
||||
<Button variant="danger" onClick={() => setPendingDelete(model)}>Excluir</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Dialog open={criteria} title="Critérios para upload" onClose={() => setCriteria(false)}>
|
||||
<p className="text-sm">XLSX ou CSV, com cabeçalho identificável. O workspace pode ter até 10 modelos ativos e o nome lógico não pode repetir.</p>
|
||||
<Button className="mt-4" onClick={() => setCriteria(false)}>Fechar</Button>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(pendingDelete)} title="Excluir modelo" onClose={() => setPendingDelete(null)}>
|
||||
<p className="text-sm">Confirme a exclusão de {pendingDelete?.name}. A operação é bloqueada se houver processamento ativo.</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setPendingDelete(null)}>Cancelar</Button>
|
||||
<Button variant="danger" onClick={async () => {
|
||||
if (!pendingDelete) return;
|
||||
const response = await fetch(`/api/spreadsheet-adapter/models/${pendingDelete.id}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
const data = (await response.json()) as { error?: { message: string } };
|
||||
if (!response.ok) setMessage(data.error?.message ?? "Exclusão bloqueada");
|
||||
setPendingDelete(null);
|
||||
await models.refetch();
|
||||
}}>Confirmar exclusão</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { FileSpreadsheet, LogOut, Settings, Table2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { canManageSpreadsheetModels } from "@/domain/spreadsheet-adapter/permissions";
|
||||
|
||||
export function AppShell({ ctx, children }: { ctx: RequestContext; children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const admin = canManageSpreadsheetModels(ctx);
|
||||
const item = (href: string, label: string, icon: ReactNode) => (
|
||||
<Link href={href} aria-current={pathname === href ? "page" : undefined} className={`flex items-center gap-2 rounded-[14px] px-3 py-2 text-sm font-medium ${pathname === href ? "bg-ink text-white" : "hover:bg-white"}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-screen md:grid md:grid-cols-[240px_1fr]">
|
||||
<aside className="border-b border-line bg-white p-4 md:border-b-0 md:border-r">
|
||||
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||
<p className="mt-1 flex items-center gap-2 text-lg font-semibold">
|
||||
<FileSpreadsheet aria-hidden="true" size={18} /> Adaptador
|
||||
</p>
|
||||
<p className="mt-3 text-sm">Workspace {ctx.workspaceName}</p>
|
||||
<p className="text-xs text-ink/70">{ctx.displayName} · {ctx.role}</p>
|
||||
<nav className="mt-6 space-y-1" aria-label="Módulo">
|
||||
{item("/adaptador", "Início", <Table2 size={16} aria-hidden="true" />)}
|
||||
{item("/adaptador/operacao", "Nova adaptação", <FileSpreadsheet size={16} aria-hidden="true" />)}
|
||||
{admin ? item("/adaptador/configuracoes", "Configurações", <Settings size={16} aria-hidden="true" />) : null}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-8 flex items-center gap-2 text-sm font-semibold"
|
||||
onClick={async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<LogOut size={16} aria-hidden="true" /> Sair do Elo Pro
|
||||
</button>
|
||||
</aside>
|
||||
<main className="p-4 md:p-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function LoadingBlock({ label = "Carregando" }: { label?: string }) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="space-y-3" aria-label={label}>
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-line" />
|
||||
<div className="h-24 animate-pulse rounded-[14px] bg-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: { title: string; description: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[14px] border border-dashed border-line bg-white p-8 text-center">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="mt-2 text-sm text-ink/75">{description}</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div role="alert" className="rounded-[14px] border border-danger bg-white p-4">
|
||||
<p className="font-semibold text-danger">Não foi possível continuar</p>
|
||||
<p className="mt-1 text-sm">{message}</p>
|
||||
{onRetry ? (
|
||||
<button type="button" className="mt-3 text-sm font-semibold underline" onClick={onRetry}>
|
||||
Tentar novamente
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
loading = false,
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "danger" | "ghost"; loading?: boolean }) {
|
||||
const styles = {
|
||||
primary: "bg-action text-white hover:bg-bright",
|
||||
secondary: "bg-white text-ink border border-line hover:border-bright",
|
||||
danger: "bg-white text-danger border border-danger",
|
||||
ghost: "bg-transparent text-ink hover:bg-white",
|
||||
}[variant];
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={props.disabled || loading}
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-[18px] px-4 text-sm font-semibold transition duration-200 ${styles} ${props.className ?? ""}`}
|
||||
>
|
||||
{loading ? "Aguarde…" : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
|
||||
return (
|
||||
<button {...props} aria-label={label} className={`inline-flex size-11 items-center justify-center rounded-[18px] border border-line bg-white ${props.className ?? ""}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={`h-11 w-full rounded-[10px] border border-line bg-white px-3 ${props.className ?? ""}`} />;
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select {...props} className={`h-11 w-full rounded-[10px] border border-line bg-white px-3 ${props.className ?? ""}`} />;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={`relative h-7 w-12 rounded-full transition ${checked ? "bg-aqua" : "bg-line"}`}
|
||||
>
|
||||
<span className={`absolute top-1 size-5 rounded-full bg-white transition ${checked ? "left-6" : "left-1"}`} />
|
||||
<span className="sr-only">{checked ? "Ligado" : "Desligado"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <section className={`rounded-[14px] border border-line bg-white p-5 shadow-sm ${className}`}>{children}</section>;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-ink/40 p-4" role="presentation" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label={title} className="max-h-[90vh] w-full max-w-lg overflow-auto rounded-[24px] bg-cloud p-6" onClick={(event) => event.stopPropagation()}>
|
||||
<h2 className="mb-3 text-xl font-semibold">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SemanticMarker({ tone, children }: { tone: "success" | "warning" | "error" | "info"; children: ReactNode }) {
|
||||
const toneClass = { success: "text-aqua", warning: "text-warning", error: "text-danger", info: "text-bright" }[tone];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 text-sm font-medium ${toneClass}`}>
|
||||
<span aria-hidden="true">{tone === "success" ? "●" : tone === "error" ? "!" : "•"}</span>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function JourneyTrail({ steps, current }: { steps: string[]; current: number }) {
|
||||
return (
|
||||
<ol className="flex flex-wrap gap-2" aria-label="Jornada da adaptação">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step} aria-current={index === current ? "step" : undefined} className={`rounded-full px-3 py-1 text-sm ${index === current ? "bg-ink text-white" : index < current ? "bg-aqua/15 text-ink" : "bg-white text-ink/70"}`}>
|
||||
{index < current ? `Concluído: ${step}` : step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user