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:
camilafagundesandCursor committed 2026-10-02 23:02:00 -03:00
commit 6e23f1533b
144 files changed
+19060

No files matched your search

+56
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+327
View File
@@ -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>
);
}
+9
View File
@@ -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>;
}
+157
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}