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