first commit

This commit is contained in:
2026-06-04 14:45:08 -03:00
commit 3e85097955
18 changed files with 3421 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
CLARIZEN_BASE_URL=https://api.clarizen.com
CLARIZEN_API_KEY=
PORT=3000
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.log
.DS_Store
uploads/
+126
View File
@@ -0,0 +1,126 @@
# Portal de importação Excel → Clarizen WorkItem
Portal web MVP para enviar um arquivo `.xlsx` e criar ou atualizar **WorkItems** no Planview Adaptive Work (Clarizen), usando [@arco/clarizen-lib](https://gitea.rivuslab.com/leonardotoniolo/clarizen-lib).
## Pré-requisitos
- [Node.js](https://nodejs.org/) 18 ou superior
- Acesso ao Gitea da Rivus Lab (para instalar `clarizen-lib` via Git)
- Chave de API do Adaptive Work
## Instalação
```bash
cd update_xlsx
npm install
```
Se o repositório `clarizen-lib` for privado, configure autenticação Git (SSH ou token HTTPS) antes do `npm install`.
## Configuração
```bash
cp .env.example .env
```
Edite `.env` na raiz do projeto:
```env
CLARIZEN_BASE_URL=https://api.clarizen.com
CLARIZEN_API_KEY=sua_chave_aqui
PORT=3000
```
Alternativa aceita: `API_KEY_AW` em vez de `CLARIZEN_API_KEY`.
O carregador também tenta `../clarizen-lib/.env` se existir (desenvolvimento local no monorepo ARCO).
## Executar
```bash
npm start
```
Abra [http://localhost:3000](http://localhost:3000).
| Comando | Descrição |
| -------- | --------- |
| `npm start` | Sobe o portal (Express + UI estática) |
| `npm run check:clarizen` | Verifica se a lib foi instalada e compilada (`OK Clarizen`) |
## Formato do Excel
- **Linha 1:** cabeçalhos — pode usar o **nome do campo** na API ou o **label** exibido no Adaptive Work (ex.: `ExternalID` ou `External ID`, `CreatedOn` ou `Created On`).
- **Linhas 2+:** um WorkItem por linha.
- Colunas vazias são ignoradas.
Na importação, o portal consulta o metadata de `WorkItem` no Clarizen e resolve cada cabeçalho para o nome técnico do campo. Colunas não reconhecidas retornam erro 400 antes de criar/atualizar registros.
### Cabeçalhos: nome vs label
| Cabeçalho no Excel | Campo enviado à API |
|--------------------|---------------------|
| `Name` | `Name` |
| `Name` (label) | `Name` |
| `ExternalID` | `ExternalID` |
| `External ID` | `ExternalID` |
| `Created On` | `CreatedOn` |
Após a importação, a UI mostra o mapeamento **Excel → API** (`headerMapping`).
### Create vs update
| Situação | Ação |
| -------- | ---- |
| Linha **sem** valor em `ExternalID` (ou coluna mapeada para ele) | **Criar** novo WorkItem |
| Linha **com** `ExternalID` preenchido | **Atualizar** WorkItem existente com esse ID externo |
Aliases de cabeçalho aceitos: `ExternalId`, `External ID`, `externalId``ExternalID`.
### Exemplo (labels)
| Name | External ID | Description | Start Date |
|------|-------------|-------------|------------|
| Tarefa nova | | Descrição da tarefa | 2026-06-01 |
| Tarefa existente | EXT-001 | Descrição atualizada | 2026-06-15 |
### Exemplo (nomes API)
| Name | ExternalID | Description | StartDate |
|------|------------|-------------|-----------|
| Tarefa nova | | Descrição da tarefa | 2026-06-01 |
| Tarefa existente | EXT-001 | Descrição atualizada | 2026-06-15 |
A lógica de create/update está em `src/import/config.ts` e `src/import/processor.ts` para facilitar alterações futuras.
## Estrutura
```text
update_xlsx/
├── .env.example
├── public/ # UI (HTML, CSS, JS)
├── src/
│ ├── server.ts
│ ├── clarizen/ # cliente e auth
│ ├── excel/ # leitura xlsx
│ ├── import/ # create/update, field-headers (name/label)
│ └── routes/ # POST /api/import
└── README.md
```
## Limitações (MVP)
- Apenas entidade **WorkItem** (sem seleção na UI).
- Campos de referência (`Manager`, `Parent`, etc.) podem exigir formato específico da API; o MVP envia valores escalares simples do Excel.
- Processamento sequencial linha a linha (sem bulk API).
## Problemas comuns
| Sintoma | O que fazer |
| -------- | ------------ |
| Erro ao clonar `clarizen-lib` no `npm install` | Verificar login SSH/HTTPS no Gitea |
| Banner amarelo na página | Conferir `.env` e `npm run check:clarizen` |
| `WorkItem não encontrado para ExternalID=...` | O ID externo não existe no tenant; use create (linha sem ExternalID) ou corrija o valor |
| Erro 400 no upload | Arquivo deve ser `.xlsx` / `.xls` com cabeçalho e ao menos uma linha de dados |
| `Colunas não reconhecidas: ...` | Cabeçalho não bate com nome nem label de campo WorkItem no seu tenant |
| `Label ambíguo` | Dois campos distintos compartilham o mesmo label — use o nome técnico do campo |
+1797
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@arco/update-xlsx-portal",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/server.ts",
"check:clarizen": "node --input-type=module -e \"import('@arco/clarizen-lib').then(m => console.log('OK', m.Clarizen.name))\""
},
"dependencies": {
"@arco/clarizen-lib": "git+https://gitea.rivuslab.com/leonardotoniolo/clarizen-lib.git#main",
"dotenv": "^16.5.0",
"express": "^4.21.0",
"multer": "^1.4.5-lts.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}
}
+211
View File
@@ -0,0 +1,211 @@
const fileInput = document.getElementById("file-input");
const fileNameEl = document.getElementById("file-name");
const importBtn = document.getElementById("import-btn");
const dropZone = document.getElementById("drop-zone");
const progress = document.getElementById("progress");
const alertSection = document.getElementById("alert-section");
const alertMessage = document.getElementById("alert-message");
const summarySection = document.getElementById("summary-section");
const summaryGrid = document.getElementById("summary-grid");
const mappingSection = document.getElementById("mapping-section");
const mappingList = document.getElementById("mapping-list");
const previewSection = document.getElementById("preview-section");
const previewTable = document.getElementById("preview-table");
const resultsSection = document.getElementById("results-section");
const resultsTable = document.getElementById("results-table");
const healthBanner = document.getElementById("health-banner");
const healthMessage = document.getElementById("health-message");
let selectedFile = null;
function showAlert(text, type = "error") {
alertSection.hidden = false;
alertMessage.textContent = text;
alertMessage.className = `alert ${type}`;
}
function hideAlert() {
alertSection.hidden = true;
}
function setFile(file) {
selectedFile = file;
fileNameEl.textContent = file ? file.name : "Nenhum arquivo selecionado";
importBtn.disabled = !file;
}
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) setFile(file);
});
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
const file = e.dataTransfer?.files?.[0];
if (file) {
setFile(file);
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
}
});
function renderPreview(preview) {
if (!preview?.headers?.length) return;
previewSection.hidden = false;
const thead = previewTable.querySelector("thead");
const tbody = previewTable.querySelector("tbody");
thead.innerHTML = `<tr>${preview.headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr>`;
tbody.innerHTML = (preview.rows ?? [])
.map(
(row) =>
`<tr>${row.map((cell) => `<td>${escapeHtml(formatCell(cell))}</td>`).join("")}</tr>`,
)
.join("");
}
function renderHeaderMapping(headerMapping) {
const entries = Object.entries(headerMapping ?? {});
if (!entries.length) return;
mappingSection.hidden = false;
mappingList.innerHTML = entries
.map(
([excel, api]) =>
`<li><span class="excel-col">${escapeHtml(excel)}</span><span class="arrow">→</span><span class="api-col">${escapeHtml(api)}</span></li>`,
)
.join("");
}
function renderSummary(summary) {
summarySection.hidden = false;
const items = [
{ label: "Total", value: summary.total },
{ label: "Criados", value: summary.created },
{ label: "Atualizados", value: summary.updated },
{ label: "Erros", value: summary.errors },
];
summaryGrid.innerHTML = items
.map(
(item) =>
`<li><strong>${item.value}</strong><span>${item.label}</span></li>`,
)
.join("");
}
function statusLabel(status) {
const map = { created: "Criado", updated: "Atualizado", error: "Erro" };
return map[status] ?? status;
}
function renderResults(results) {
if (!results?.length) return;
resultsSection.hidden = false;
const tbody = resultsTable.querySelector("tbody");
tbody.innerHTML = results
.map((r) => {
const idPart = [r.id, r.externalId].filter(Boolean).join(" / ") || "—";
return `<tr>
<td>${r.row}</td>
<td><span class="badge ${r.status}">${statusLabel(r.status)}</span></td>
<td>${escapeHtml(idPart)}</td>
<td>${escapeHtml(r.message)}</td>
</tr>`;
})
.join("");
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatCell(cell) {
if (cell === null || cell === undefined) return "";
return String(cell);
}
importBtn.addEventListener("click", async () => {
if (!selectedFile) return;
hideAlert();
previewSection.hidden = true;
summarySection.hidden = true;
mappingSection.hidden = true;
resultsSection.hidden = true;
progress.hidden = false;
importBtn.disabled = true;
const formData = new FormData();
formData.append("file", selectedFile);
try {
const res = await fetch("/api/import", {
method: "POST",
body: formData,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showAlert(data.error ?? `Erro ${res.status} ao importar`);
return;
}
renderSummary(data.summary);
renderHeaderMapping(data.headerMapping);
renderPreview(data.preview);
renderResults(data.results);
const { errors, total } = data.summary ?? {};
if (errors === 0) {
showAlert(`Importação concluída: ${total} linha(s) processada(s).`, "success");
} else {
showAlert(
`Importação concluída com ${errors} erro(s) em ${total} linha(s).`,
"error",
);
}
} catch (err) {
showAlert(err instanceof Error ? err.message : "Falha de rede ao importar");
} finally {
progress.hidden = true;
importBtn.disabled = !selectedFile;
}
});
async function checkHealth() {
try {
const res = await fetch("/api/health");
const data = await res.json();
healthBanner.hidden = false;
if (data.ok) {
healthBanner.className = "card banner-ok";
healthMessage.textContent = `Conectado ao Clarizen (usuário: ${data.userId})`;
} else {
healthBanner.className = "card banner-warn";
healthMessage.textContent =
data.error ?? "Não foi possível autenticar no Clarizen";
}
} catch {
healthBanner.hidden = false;
healthBanner.className = "card banner-warn";
healthMessage.textContent =
"Servidor indisponível. Inicie com npm start.";
}
}
checkHealth();
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ARCO — Importação Excel → Adaptive Work</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page">
<header class="header">
<p class="eyebrow">Planview Adaptive Work</p>
<h1>Importação Excel → WorkItem</h1>
<p class="subtitle">
Envie um arquivo <code>.xlsx</code> para criar ou atualizar WorkItems no Clarizen.
Os cabeçalhos podem usar o <strong>nome do campo</strong> ou o <strong>rótulo (label)</strong> do Adaptive Work.
</p>
</header>
<main class="main">
<section class="card" id="health-banner" hidden>
<p class="banner-text" id="health-message"></p>
</section>
<section class="card upload-card">
<label class="upload-zone" id="drop-zone" for="file-input">
<input
type="file"
id="file-input"
name="file"
accept=".xlsx,.xls"
hidden
/>
<span class="upload-icon" aria-hidden="true">📄</span>
<span class="upload-title">Arraste o Excel aqui ou clique para escolher</span>
<span class="upload-hint">Apenas .xlsx — linha 1 = nome ou label do campo</span>
<span class="file-name" id="file-name">Nenhum arquivo selecionado</span>
</label>
<div class="actions">
<button type="button" class="btn btn-primary" id="import-btn" disabled>
Importar
</button>
</div>
<div class="progress" id="progress" hidden>
<div class="progress-bar"></div>
</div>
</section>
<section class="card" id="alert-section" hidden>
<p id="alert-message" class="alert"></p>
</section>
<section class="card" id="summary-section" hidden>
<h2>Resumo</h2>
<ul class="summary-grid" id="summary-grid"></ul>
</section>
<section class="card" id="mapping-section" hidden>
<h2>Colunas reconhecidas</h2>
<ul class="mapping-list" id="mapping-list"></ul>
</section>
<section class="card" id="preview-section" hidden>
<h2>Prévia dos dados</h2>
<div class="table-wrap">
<table id="preview-table">
<thead></thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="card" id="results-section" hidden>
<h2>Resultado por linha</h2>
<div class="table-wrap">
<table id="results-table">
<thead>
<tr>
<th>Linha</th>
<th>Status</th>
<th>ID / ExternalID</th>
<th>Mensagem</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
</main>
<footer class="footer">
<span>MVP — entidade fixa: WorkItem</span>
</footer>
</div>
<script src="/app.js" type="module"></script>
</body>
</html>
+358
View File
@@ -0,0 +1,358 @@
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--text: #1a2332;
--muted: #5c6b7a;
--border: #e2e8f0;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--success: #059669;
--success-bg: #ecfdf5;
--warning: #d97706;
--warning-bg: #fffbeb;
--error: #dc2626;
--error-bg: #fef2f2;
--updated: #7c3aed;
--updated-bg: #f5f3ff;
--radius: 12px;
--shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
.header {
margin-bottom: 2rem;
}
.eyebrow {
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--primary);
}
.header h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.subtitle {
margin: 0;
color: var(--muted);
max-width: 36rem;
}
.subtitle code {
font-size: 0.9em;
background: var(--border);
padding: 0.1em 0.4em;
border-radius: 4px;
}
.main {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
#health-banner.banner-warn {
background: var(--warning-bg);
border-color: #fcd34d;
}
#health-banner.banner-ok {
background: var(--success-bg);
border-color: #6ee7b7;
}
.banner-text {
margin: 0;
font-size: 0.9rem;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1.5rem;
border: 2px dashed var(--border);
border-radius: var(--radius);
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
text-align: center;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--primary);
background: #eff6ff;
}
.upload-icon {
font-size: 2rem;
}
.upload-title {
font-weight: 600;
}
.upload-hint,
.file-name {
font-size: 0.875rem;
color: var(--muted);
}
.file-name {
margin-top: 0.25rem;
}
.actions {
margin-top: 1.25rem;
display: flex;
justify-content: flex-end;
}
.btn {
font: inherit;
font-weight: 600;
padding: 0.65rem 1.5rem;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.progress {
margin-top: 1rem;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 40%;
background: var(--primary);
border-radius: 2px;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(350%);
}
}
.alert {
margin: 0;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.95rem;
}
.alert.success {
background: var(--success-bg);
color: var(--success);
}
.alert.error {
background: var(--error-bg);
color: var(--error);
}
.summary-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.summary-grid li {
text-align: center;
padding: 0.75rem;
background: var(--bg);
border-radius: 8px;
}
.summary-grid strong {
display: block;
font-size: 1.5rem;
font-weight: 700;
}
.summary-grid span {
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mapping-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
}
.mapping-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 8px;
}
.mapping-list .excel-col {
color: var(--text);
font-weight: 500;
}
.mapping-list .arrow {
color: var(--muted);
}
.mapping-list .api-col {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
color: var(--primary);
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th,
td {
padding: 0.6rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
background: var(--bg);
font-weight: 600;
white-space: nowrap;
}
tbody tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 0.2em 0.55em;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge.created {
background: var(--success-bg);
color: var(--success);
}
.badge.updated {
background: var(--updated-bg);
color: var(--updated);
}
.badge.error {
background: var(--error-bg);
color: var(--error);
}
.footer {
margin-top: 2.5rem;
text-align: center;
font-size: 0.8rem;
color: var(--muted);
}
@media (max-width: 600px) {
.page {
padding: 1.25rem 1rem 2rem;
}
.card {
padding: 1rem;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Clarizen, ClarizenApiError } from "@arco/clarizen-lib";
export function getApiKey(): string | undefined {
return process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
}
export function createClarizenClient(): Clarizen {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"Defina CLARIZEN_API_KEY ou API_KEY_AW no arquivo .env na raiz do projeto.",
);
}
return new Clarizen({
baseUrl: process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com",
apiToken: apiKey,
});
}
export async function checkClarizenAuth(
clarizen: Clarizen,
): Promise<{ ok: true; userId: string } | { ok: false; message: string; status?: number }> {
try {
const session = await clarizen.authentication.getSessionInfo();
return { ok: true, userId: session.userId };
} catch (err) {
if (err instanceof ClarizenApiError) {
return {
ok: false,
message: err.message,
status: err.status === 401 || err.status === 403 ? err.status : 502,
};
}
const message = err instanceof Error ? err.message : "Falha de autenticação";
return { ok: false, message, status: 502 };
}
}
+152
View File
@@ -0,0 +1,152 @@
import * as XLSX from "xlsx";
import type { HeaderResolver } from "../import/field-headers.js";
import { HeaderResolveError } from "../import/field-headers.js";
export interface ParsedSheet {
headers: string[];
rows: Record<string, string | number | boolean | null>[];
}
export class ExcelParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ExcelParseError";
}
}
function cellValue(value: unknown): string | number | boolean | null {
if (value === undefined || value === null || value === "") {
return null;
}
if (typeof value === "boolean" || typeof value === "number") {
return value;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
return String(value).trim() || null;
}
function resolveHeaders(
rawHeaders: unknown[],
resolver: HeaderResolver,
): string[] {
const resolved: string[] = [];
const unknown: string[] = [];
for (const raw of rawHeaders) {
const text = String(raw ?? "").trim();
if (!text) continue;
try {
resolved.push(resolver.resolve(text));
} catch (err) {
if (err instanceof HeaderResolveError) {
if (err.kind === "ambiguous") {
throw new ExcelParseError(err.message);
}
unknown.push(err.header || text);
} else {
throw err;
}
}
}
if (unknown.length) {
throw new ExcelParseError(
`Colunas não reconhecidas: ${unknown.join(", ")}. Use o nome do campo ou o label exibido no Adaptive Work.`,
);
}
return resolved;
}
export function parseExcelBuffer(
buffer: Buffer,
resolver: HeaderResolver,
): ParsedSheet {
const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new ExcelParseError("O arquivo Excel não contém planilhas.");
}
const sheet = workbook.Sheets[sheetName];
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: null,
raw: false,
}) as unknown[][];
if (!matrix.length) {
throw new ExcelParseError("A planilha está vazia.");
}
const rawHeaders = matrix[0] ?? [];
const headers = resolveHeaders(rawHeaders, resolver);
if (!headers.length) {
throw new ExcelParseError(
"A primeira linha deve conter cabeçalhos de coluna válidos.",
);
}
const columnCount = rawHeaders.filter(
(h) => String(h ?? "").trim().length > 0,
).length;
if (headers.length !== columnCount) {
throw new ExcelParseError(
"Não foi possível resolver todos os cabeçalhos da primeira linha.",
);
}
const rows: ParsedSheet["rows"] = [];
for (let i = 1; i < matrix.length; i++) {
const line = matrix[i] ?? [];
const record: Record<string, string | number | boolean | null> = {};
let hasValue = false;
let colIndex = 0;
for (let c = 0; c < rawHeaders.length; c++) {
const raw = String(rawHeaders[c] ?? "").trim();
if (!raw) continue;
const header = headers[colIndex];
colIndex++;
if (!header) continue;
const value = cellValue(line[c]);
if (value !== null) {
hasValue = true;
}
record[header] = value;
}
if (hasValue) {
rows.push(record);
}
}
if (!rows.length) {
throw new ExcelParseError(
"Nenhuma linha de dados encontrada após o cabeçalho.",
);
}
return { headers, rows };
}
export function buildPreview(parsed: ParsedSheet, limit: number): {
headers: string[];
rows: (string | number | boolean | null)[][];
} {
const slice = parsed.rows.slice(0, limit);
return {
headers: parsed.headers,
rows: slice.map((row) =>
parsed.headers.map((h) => row[h] ?? null),
),
};
}
+37
View File
@@ -0,0 +1,37 @@
/** Entity type for all Excel rows in this MVP. */
export const ENTITY_TYPE = "WorkItem";
/** Excel column names that trigger update mode when populated. */
export const UPDATE_ID_FIELDS = ["ExternalID", "ExternalId", "externalId"] as const;
/** Clarizen API field used in entityQuery for lookup. */
export const ID_FIELD_API = "ExternalID";
/** Map Excel header aliases to Clarizen field names (before metadata lookup). */
export const HEADER_ALIASES: Record<string, string> = {
ExternalId: "ExternalID",
externalId: "ExternalID",
externalid: "ExternalID",
"External ID": "ExternalID",
"external id": "ExternalID",
};
export const PREVIEW_ROW_LIMIT = 20;
/**
* Keys that must never be sent in create/update body.
* "id" is the entity URI segment (also auto-returned by EntityQuery), not a WorkItem field.
* SYSID (label "ID") is read-only/calculated.
*/
export const PAYLOAD_EXCLUDED_KEYS = new Set([
"id",
"Id",
"ID",
"SYSID",
ID_FIELD_API,
...UPDATE_ID_FIELDS,
]);
export function isPayloadExcludedKey(key: string): boolean {
return PAYLOAD_EXCLUDED_KEYS.has(key);
}
+172
View File
@@ -0,0 +1,172 @@
import type { Clarizen, DescribeMetadataResult } from "@arco/clarizen-lib";
import { ENTITY_TYPE, HEADER_ALIASES } from "./config.js";
const CACHE_TTL_MS = 15 * 60 * 1000;
export function normalizeHeaderKey(raw: string): string {
return raw.trim().replace(/\s+/g, " ").toLowerCase();
}
export class HeaderResolveError extends Error {
constructor(
message: string,
readonly kind: "unknown" | "ambiguous",
readonly header: string,
readonly fieldNames?: string[],
) {
super(message);
this.name = "HeaderResolveError";
}
}
type LookupEntry =
| { kind: "ok"; fieldName: string }
| { kind: "ambiguous"; fieldNames: string[] };
function registerKey(
lookup: Map<string, LookupEntry>,
key: string,
fieldName: string,
): void {
if (!key) return;
const existing = lookup.get(key);
if (!existing) {
lookup.set(key, { kind: "ok", fieldName });
return;
}
if (existing.kind === "ambiguous") {
if (!existing.fieldNames.includes(fieldName)) {
existing.fieldNames.push(fieldName);
}
return;
}
if (existing.fieldName === fieldName) return;
lookup.set(key, {
kind: "ambiguous",
fieldNames: [existing.fieldName, fieldName],
});
}
function buildLookupFromFields(
fields: Array<{ name?: string; label?: string }>,
): Map<string, LookupEntry> {
const lookup = new Map<string, LookupEntry>();
for (const field of fields) {
const name = field.name?.trim();
if (!name) continue;
registerKey(lookup, normalizeHeaderKey(name), name);
const label = field.label?.trim();
if (label) {
registerKey(lookup, normalizeHeaderKey(label), name);
}
}
for (const [alias, fieldName] of Object.entries(HEADER_ALIASES)) {
registerKey(lookup, normalizeHeaderKey(alias), fieldName);
}
return lookup;
}
export interface HeaderResolver {
resolve(raw: string): string;
readonly mapping: Record<string, string>;
}
function createResolver(lookup: Map<string, LookupEntry>): HeaderResolver {
const mapping: Record<string, string> = {};
return {
mapping,
resolve(raw: string): string {
const original = String(raw ?? "").trim();
if (!original) {
throw new HeaderResolveError(
"Cabeçalho de coluna vazio.",
"unknown",
original,
);
}
const aliased = HEADER_ALIASES[original] ?? original;
const key = normalizeHeaderKey(aliased);
const entry = lookup.get(key);
if (!entry) {
throw new HeaderResolveError(
`Coluna não reconhecida: "${original}". Use o nome do campo ou o label do Adaptive Work.`,
"unknown",
original,
);
}
if (entry.kind === "ambiguous") {
throw new HeaderResolveError(
`Label ambíguo "${original}" corresponde a: ${entry.fieldNames.join(", ")}`,
"ambiguous",
original,
entry.fieldNames,
);
}
mapping[original] = entry.fieldName;
return entry.fieldName;
},
};
}
let cached:
| { lookup: Map<string, LookupEntry>; expiresAt: number }
| null = null;
async function fetchWorkItemFieldLookup(
clarizen: Clarizen,
): Promise<Map<string, LookupEntry>> {
// Use call() — metadata.describeMetadata in clarizen-lib recurses (service === base).
const result = await clarizen.call<DescribeMetadataResult>(
"metadata",
"DescribeMetadata",
undefined,
{
typeNames: [ENTITY_TYPE],
flags: ["fields"],
},
);
const entity = result.entityDescriptions?.find(
(e) => e.typeName === ENTITY_TYPE,
);
const fields = entity?.fields ?? [];
if (!fields.length) {
throw new Error(
`Metadata de ${ENTITY_TYPE} não retornou campos. Verifique permissões da API.`,
);
}
return buildLookupFromFields(fields);
}
export async function buildWorkItemHeaderResolver(
clarizen: Clarizen,
): Promise<HeaderResolver> {
const now = Date.now();
if (cached && cached.expiresAt > now) {
return createResolver(cached.lookup);
}
const lookup = await fetchWorkItemFieldLookup(clarizen);
cached = { lookup, expiresAt: now + CACHE_TTL_MS };
return createResolver(lookup);
}
/** Clears cached metadata (useful for tests). */
export function clearHeaderResolverCache(): void {
cached = null;
}
+169
View File
@@ -0,0 +1,169 @@
import type { Clarizen, JsonObject, JsonValue } from "@arco/clarizen-lib";
import { ClarizenApiError } from "@arco/clarizen-lib";
import type { ParsedSheet } from "../excel/parse.js";
import { buildPreview } from "../excel/parse.js";
import {
ENTITY_TYPE,
ID_FIELD_API,
PREVIEW_ROW_LIMIT,
UPDATE_ID_FIELDS,
isPayloadExcludedKey,
} from "./config.js";
import { findWorkItemIdByExternalId, ResolverError } from "./resolver.js";
export type RowStatus = "created" | "updated" | "error";
export interface RowResult {
row: number;
status: RowStatus;
message: string;
id?: string;
externalId?: string;
errorCode?: string;
}
export interface ImportSummary {
total: number;
created: number;
updated: number;
errors: number;
}
export interface ImportResponse {
summary: ImportSummary;
preview: { headers: string[]; rows: (string | number | boolean | null)[][] };
results: RowResult[];
}
function getUpdateIdValue(
row: Record<string, string | number | boolean | null>,
): string | null {
for (const field of UPDATE_ID_FIELDS) {
const apiField = field === "ExternalId" || field === "externalId"
? ID_FIELD_API
: field;
const value = row[apiField] ?? row[field];
if (value !== null && value !== undefined && String(value).trim() !== "") {
return String(value).trim();
}
}
return null;
}
function toApiFields(
row: Record<string, string | number | boolean | null>,
): JsonObject {
const fields: JsonObject = {};
for (const [key, value] of Object.entries(row)) {
if (value === null || value === undefined) continue;
if (isPayloadExcludedKey(key)) continue;
fields[key] = value as JsonValue;
}
return fields;
}
function validateRowFields(
fields: JsonObject,
hasUpdateId: boolean,
): string | null {
const keys = Object.keys(fields);
if (keys.length === 0) {
return "Linha sem campos para enviar ao Clarizen.";
}
if (!hasUpdateId && keys.length === 0) {
return "Linha vazia ou sem campos válidos.";
}
return null;
}
export async function processImport(
clarizen: Clarizen,
parsed: ParsedSheet,
): Promise<ImportResponse> {
const results: RowResult[] = [];
const summary: ImportSummary = {
total: parsed.rows.length,
created: 0,
updated: 0,
errors: 0,
};
for (let i = 0; i < parsed.rows.length; i++) {
const excelRow = i + 2;
const row = parsed.rows[i];
const externalId = getUpdateIdValue(row);
try {
if (externalId) {
const fields = toApiFields(row);
const validationError = validateRowFields(fields, true);
if (validationError) {
throw new Error(validationError);
}
const entityId = await findWorkItemIdByExternalId(clarizen, externalId);
await clarizen.objects.update(ENTITY_TYPE, entityId, fields);
summary.updated++;
results.push({
row: excelRow,
status: "updated",
id: entityId,
externalId,
message: "Atualizado com sucesso",
});
console.log(`[import] row ${excelRow}: updated ${entityId}`);
} else {
const fields = toApiFields(row);
const validationError = validateRowFields(fields, false);
if (validationError) {
throw new Error(validationError);
}
const { id } = await clarizen.objects.create(ENTITY_TYPE, fields);
summary.created++;
results.push({
row: excelRow,
status: "created",
id,
message: "Criado com sucesso",
});
console.log(`[import] row ${excelRow}: created ${id}`);
}
} catch (err) {
summary.errors++;
let message: string;
let errorCode: string | undefined;
if (err instanceof ResolverError) {
message = err.message;
errorCode = err.code;
} else if (err instanceof ClarizenApiError) {
message = err.message;
errorCode = err.errorCode;
} else if (err instanceof Error) {
message = err.message;
} else {
message = "Erro desconhecido ao processar linha";
}
results.push({
row: excelRow,
status: "error",
externalId: externalId ?? undefined,
message,
errorCode,
});
console.error(`[import] row ${excelRow}: error — ${message}`);
}
}
return {
summary,
preview: buildPreview(parsed, PREVIEW_ROW_LIMIT),
results,
};
}
+56
View File
@@ -0,0 +1,56 @@
import type { Clarizen } from "@arco/clarizen-lib";
import { ENTITY_TYPE, ID_FIELD_API } from "./config.js";
export class ResolverError extends Error {
readonly code: "not_found" | "ambiguous";
constructor(code: "not_found" | "ambiguous", message: string) {
super(message);
this.name = "ResolverError";
this.code = code;
}
}
export async function findWorkItemIdByExternalId(
clarizen: Clarizen,
externalId: string,
): Promise<string> {
// "id" is returned on each entity but is not a queryable field name — only list real fields.
const result = await clarizen.data.entityQuery({
typeName: ENTITY_TYPE,
fields: [ID_FIELD_API],
where: {
leftExpression: { fieldName: ID_FIELD_API },
operator: "Equal",
rightExpression: { value: externalId },
},
paging: { limit: 2, from: 0 },
});
const entities = result.entities ?? [];
if (entities.length === 0) {
throw new ResolverError(
"not_found",
`WorkItem não encontrado para ${ID_FIELD_API}=${externalId}`,
);
}
if (entities.length > 1) {
throw new ResolverError(
"ambiguous",
`Múltiplos WorkItems encontrados para ${ID_FIELD_API}=${externalId}`,
);
}
const entity = entities[0];
const id = entity.id;
if (!id || typeof id !== "string") {
throw new ResolverError(
"not_found",
`WorkItem encontrado sem id interno para ${ID_FIELD_API}=${externalId}`,
);
}
return id;
}
+16
View File
@@ -0,0 +1,16 @@
import { config } from "dotenv";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
for (const envPath of [
resolve(rootDir, ".env"),
resolve(rootDir, "../clarizen-lib/.env"),
]) {
if (existsSync(envPath)) {
config({ path: envPath });
break;
}
}
+80
View File
@@ -0,0 +1,80 @@
import { Router } from "express";
import multer from "multer";
import { checkClarizenAuth, createClarizenClient, getApiKey } from "../clarizen/client.js";
import { ExcelParseError, parseExcelBuffer } from "../excel/parse.js";
import { buildWorkItemHeaderResolver } from "../import/field-headers.js";
import { processImport } from "../import/processor.js";
import { ClarizenApiError } from "@arco/clarizen-lib";
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const name = file.originalname.toLowerCase();
if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
cb(null, true);
} else {
cb(new Error("Envie um arquivo .xlsx ou .xls"));
}
},
});
export const importRouter = Router();
importRouter.post(
"/import",
upload.single("file"),
async (req, res) => {
try {
if (!getApiKey()) {
res.status(500).json({
error: "Defina CLARIZEN_API_KEY ou API_KEY_AW no arquivo .env",
});
return;
}
if (!req.file) {
res.status(400).json({ error: "Nenhum arquivo enviado. Use o campo 'file'." });
return;
}
const clarizen = createClarizenClient();
const auth = await checkClarizenAuth(clarizen);
if (!auth.ok) {
res.status(auth.status ?? 502).json({
error: `Falha de autenticação no Clarizen: ${auth.message}`,
});
return;
}
const resolver = await buildWorkItemHeaderResolver(clarizen);
const parsed = parseExcelBuffer(req.file.buffer, resolver);
const result = await processImport(clarizen, parsed);
res.json({ ...result, headerMapping: resolver.mapping });
} catch (err) {
if (err instanceof ExcelParseError) {
res.status(400).json({ error: err.message });
return;
}
if (err instanceof multer.MulterError) {
res.status(400).json({ error: err.message });
return;
}
if (err instanceof Error && err.message.includes(".xlsx")) {
res.status(400).json({ error: err.message });
return;
}
if (err instanceof ClarizenApiError) {
res.status(err.status >= 400 && err.status < 600 ? err.status : 502).json({
error: err.message,
errorCode: err.errorCode,
});
return;
}
console.error("[import]", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Erro interno ao importar",
});
}
},
);
+63
View File
@@ -0,0 +1,63 @@
import "./load-env.js";
import express from "express";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkClarizenAuth, createClarizenClient, getApiKey } from "./clarizen/client.js";
import { importRouter } from "./routes/import.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const publicDir = resolve(__dirname, "../public");
const port = Number(process.env.PORT ?? 3000);
const app = express();
app.use(express.json());
app.use(express.static(publicDir));
app.get("/api/health", async (_req, res) => {
if (!getApiKey()) {
res.status(500).json({
ok: false,
error: "Defina CLARIZEN_API_KEY ou API_KEY_AW no arquivo .env",
});
return;
}
try {
const clarizen = createClarizenClient();
const auth = await checkClarizenAuth(clarizen);
if (!auth.ok) {
res.status(auth.status ?? 502).json({ ok: false, error: auth.message });
return;
}
res.json({ ok: true, userId: auth.userId });
} catch (err) {
const message = err instanceof Error ? err.message : "Erro de saúde";
res.status(500).json({ ok: false, error: message });
}
});
app.use("/api", importRouter);
app.use(
(
err: Error,
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
console.error("[server]", err);
res.status(500).json({
error: err.message || "Erro interno do servidor",
});
},
);
app.listen(port, () => {
console.log(`Portal de importação: http://localhost:${port}`);
if (!getApiKey()) {
console.warn(
"AVISO: CLARIZEN_API_KEY não configurada. Crie .env a partir de .env.example",
);
}
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true,
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}