first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
CLARIZEN_BASE_URL=https://api.clarizen.com
|
||||||
|
CLARIZEN_API_KEY=
|
||||||
|
PORT=3000
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
uploads/
|
||||||
@@ -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 |
|
||||||
Generated
+1797
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user