Compare commits
No commits in common. "0dcf7193e0d8b8c32605880a557c8f7fcf159f69" and "aa5ab4b653b804ceeaf621f0ef7c6a25b3bbd865" have entirely different histories.
0dcf7193e0
...
aa5ab4b653
44
README.md
44
README.md
|
|
@ -102,8 +102,7 @@ If multiple templates share the same name, the most recently modified one is use
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
The web UI is an enterprise-grade migration console with a Docusign-branded left-nav
|
The web UI provides a browser-based interface for connecting both platforms, browsing templates side-by-side, and running migrations with live status feedback.
|
||||||
shell, multi-customer project context, and a full migration workflow.
|
|
||||||
|
|
||||||
**Additional `.env` keys required for the web UI:**
|
**Additional `.env` keys required for the web UI:**
|
||||||
```
|
```
|
||||||
|
|
@ -119,36 +118,15 @@ uvicorn web.app:app --reload --port 8000
|
||||||
```
|
```
|
||||||
Then open [http://localhost:8000](http://localhost:8000) in your browser.
|
Then open [http://localhost:8000](http://localhost:8000) in your browser.
|
||||||
|
|
||||||
### Navigation
|
**Using the UI:**
|
||||||
|
1. Click **Connect Adobe Sign** in the header — you'll be redirected to Adobe Sign OAuth. Authorize and you'll return to the app.
|
||||||
| Screen | Path | Purpose |
|
2. Click **Connect DocuSign** — same flow for DocuSign.
|
||||||
|---|---|---|
|
3. Your Adobe Sign templates appear on the left with status badges:
|
||||||
| Templates | `#/templates` | Filterable table with readiness badges; bulk migration |
|
- **Not Migrated** (red) — no matching DocuSign template yet
|
||||||
| Migration Results | `#/results` | Summary + per-template results from last migration |
|
- **Migrated** (green) — a DocuSign template with the same name exists and is up to date
|
||||||
| Issues & Warnings | `#/issues` | All templates with blockers or warnings |
|
- **Needs Update** (yellow) — the Adobe template was modified after the last migration
|
||||||
| Verification | `#/verify` | Send test envelopes; confirm templates work end-to-end |
|
4. Check one or more templates and click **Migrate Selected**.
|
||||||
| History & Audit | `#/history` | Full migration history, filters, CSV export |
|
5. Migration results appear inline; the history table at the bottom logs all past runs.
|
||||||
| Settings | `#/settings` | Verification defaults, migration defaults, connection info |
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. **Create a project** — the switcher modal opens on first run; name it after the customer.
|
|
||||||
2. **Connect platforms** — click the Adobe Sign and DocuSign chips in the top bar.
|
|
||||||
3. **Review templates** — the Templates view shows readiness badges:
|
|
||||||
- **Ready** (green) — no issues, safe to migrate
|
|
||||||
- **Caveats** (amber) — warnings exist; migration will proceed but check Issues view
|
|
||||||
- **Blocked** (red) — blockers found; migration will fail until resolved
|
|
||||||
- **Migrated** (cobalt) — successfully migrated and up to date
|
|
||||||
- **Needs Update** (amber) — Adobe template modified after last migration
|
|
||||||
4. **Resolve issues** — check Issues & Warnings before migrating blocked templates.
|
|
||||||
5. **Migrate** — select templates, click Migrate Selected, configure options (dry run, overwrite, target folder), monitor progress.
|
|
||||||
6. **Verify** — on the Verification screen, send test envelopes and confirm receipt.
|
|
||||||
7. **Audit** — History & Audit logs every migration with checksums and export.
|
|
||||||
|
|
||||||
### Project / customer context
|
|
||||||
|
|
||||||
The project switcher (nav footer) stores per-customer migration context in `localStorage`.
|
|
||||||
Create one project per customer to keep history and settings separate.
|
|
||||||
|
|
||||||
**API docs:** [http://localhost:8000/api/docs](http://localhost:8000/api/docs)
|
**API docs:** [http://localhost:8000/api/docs](http://localhost:8000/api/docs)
|
||||||
|
|
||||||
|
|
@ -157,7 +135,7 @@ Create one project per customer to keep history and settings separate.
|
||||||
## Running tests
|
## Running tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest tests/ -v # full suite (118 tests)
|
pytest tests/ -v # full suite (108 tests)
|
||||||
pytest tests/test_regression.py -v # compose regression only
|
pytest tests/test_regression.py -v # compose regression only
|
||||||
pytest tests/test_regression.py --update-snapshots # regenerate snapshots after intentional changes
|
pytest tests/test_regression.py --update-snapshots # regenerate snapshots after intentional changes
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,573 +0,0 @@
|
||||||
# UI Redesign — Implementation Plan
|
|
||||||
|
|
||||||
*Branch: `ui-redesign` | Last updated: 2026-04-21*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Replace the basic Phase 6 single-page app (`web/static/`) with the enterprise-grade
|
|
||||||
migration console designed in `docs/ui-mockup/mockup.html`.
|
|
||||||
|
|
||||||
The backend is complete (Phases 8–13, 108/108 tests passing). All new UI phases are
|
|
||||||
**frontend-only** unless noted. Existing FastAPI routes do not change except where
|
|
||||||
noted under Phase 16 (readiness data) and Phase 19 (Verification API).
|
|
||||||
|
|
||||||
### Design reference
|
|
||||||
|
|
||||||
Open `docs/ui-mockup/mockup.html` in a browser to see all 8 screens before starting.
|
|
||||||
|
|
||||||
### Docusign 2024 brand tokens
|
|
||||||
|
|
||||||
| Token | Value | Usage |
|
|
||||||
|---|---|---|
|
|
||||||
| Cobalt | `#4C00FF` | Primary CTA, active nav highlight |
|
|
||||||
| Inkwell | `#130032` | Left nav background |
|
|
||||||
| Ecru | `#F8F3F0` | Page background |
|
|
||||||
| Poppy | `#FF5252` | Error / Blocked badge |
|
|
||||||
| Slate | `#6B6B9A` | Secondary text, muted labels |
|
|
||||||
| White | `#FFFFFF` | Card surfaces |
|
|
||||||
|
|
||||||
Typography: `Inter` (Google Fonts), fallback `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current state
|
|
||||||
|
|
||||||
`web/static/` — three files, ~600 lines total:
|
|
||||||
- `index.html` — 79 lines, single-page layout (header, two panels, history table)
|
|
||||||
- `app.js` — 343 lines, vanilla JS (auth, template list, migrate, history)
|
|
||||||
- `style.css` — 186 lines, basic styles, non-Docusign colours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File structure after redesign
|
|
||||||
|
|
||||||
Keep no-build-step approach (vanilla JS ES modules, no bundler). Split monolith into
|
|
||||||
logical files served statically by FastAPI.
|
|
||||||
|
|
||||||
```
|
|
||||||
web/static/
|
|
||||||
index.html # app shell (nav, router outlet, modals)
|
|
||||||
css/
|
|
||||||
tokens.css # CSS custom properties (brand colours, spacing)
|
|
||||||
base.css # reset, typography, utility classes
|
|
||||||
nav.css # left sidebar nav + top bar
|
|
||||||
cards.css # template cards, readiness badges
|
|
||||||
modals.css # dialog / modal styles
|
|
||||||
tables.css # history and audit tables
|
|
||||||
forms.css # settings form inputs
|
|
||||||
js/
|
|
||||||
state.js # global app state (project, auth, templates)
|
|
||||||
router.js # hash-based client-side router
|
|
||||||
api.js # thin fetch wrappers for all backend endpoints
|
|
||||||
auth.js # auth status, connect/disconnect, Adobe dialog
|
|
||||||
project.js # project switcher modal, project CRUD (localStorage)
|
|
||||||
templates.js # template list view, readiness badges, filters
|
|
||||||
migration.js # options modal, progress polling, results view
|
|
||||||
verification.js # send test envelope, poll status
|
|
||||||
history.js # history & audit view
|
|
||||||
settings.js # settings screen
|
|
||||||
utils.js # escHtml, formatDate, debounce, etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
`app.js` and `style.css` are **deleted** (replaced by the above).
|
|
||||||
`index.html` is **rewritten** as the app shell.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 14 — App Shell & Navigation
|
|
||||||
|
|
||||||
**Goal:** Branded shell that all other views live inside. No functional logic yet —
|
|
||||||
just the frame, router, and state container.
|
|
||||||
|
|
||||||
### index.html structure
|
|
||||||
|
|
||||||
```html
|
|
||||||
<body>
|
|
||||||
<nav id="app-nav"> <!-- left sidebar, 220px, Inkwell bg -->
|
|
||||||
<div id="nav-logo">…</div> <!-- docusign SVG logo, white wordmark -->
|
|
||||||
<ul id="nav-links">…</ul> <!-- 7 nav links with icons -->
|
|
||||||
<div id="nav-project">…</div> <!-- project switcher footer -->
|
|
||||||
</nav>
|
|
||||||
<div id="app-body">
|
|
||||||
<header id="top-bar">…</header> <!-- breadcrumb + auth chips -->
|
|
||||||
<main id="router-outlet">…</main> <!-- views injected here -->
|
|
||||||
</div>
|
|
||||||
<!-- modal containers -->
|
|
||||||
<div id="modal-overlay" hidden>…</div>
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
### css/tokens.css
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--cobalt: #4C00FF;
|
|
||||||
--inkwell: #130032;
|
|
||||||
--ecru: #F8F3F0;
|
|
||||||
--poppy: #FF5252;
|
|
||||||
--slate: #6B6B9A;
|
|
||||||
--white: #FFFFFF;
|
|
||||||
--success: #28A745;
|
|
||||||
--warning: #F0A500;
|
|
||||||
--border: #E0DCF8;
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--shadow-sm: 0 1px 4px rgba(0,0,0,0.08);
|
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.12);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### js/router.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
const ROUTES = {
|
|
||||||
'#/dashboard': () => import('./templates.js').then(m => m.renderDashboard()),
|
|
||||||
'#/templates': () => import('./templates.js').then(m => m.renderTemplates()),
|
|
||||||
'#/results': () => import('./migration.js').then(m => m.renderResults()),
|
|
||||||
'#/issues': () => import('./issues.js').then(m => m.renderIssues()),
|
|
||||||
'#/verify': () => import('./verification.js').then(m => m.renderVerification()),
|
|
||||||
'#/history': () => import('./history.js').then(m => m.renderHistory()),
|
|
||||||
'#/settings': () => import('./settings.js').then(m => m.renderSettings()),
|
|
||||||
};
|
|
||||||
// Default route: #/templates
|
|
||||||
```
|
|
||||||
|
|
||||||
### js/state.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export const state = {
|
|
||||||
project: null, // { id, name } — loaded from localStorage
|
|
||||||
auth: { adobe: false, docusign: false },
|
|
||||||
templates: [], // array from /api/templates/status
|
|
||||||
selectedIds: new Set(),
|
|
||||||
lastMigrationResults: null, // results from most recent batch job
|
|
||||||
};
|
|
||||||
// Simple pub/sub: subscribe(key, fn) / publish(key)
|
|
||||||
```
|
|
||||||
|
|
||||||
### js/api.js — endpoint wrappers
|
|
||||||
|
|
||||||
All existing endpoints wrapped:
|
|
||||||
```js
|
|
||||||
export const api = {
|
|
||||||
auth: {
|
|
||||||
status: () => GET('/api/auth/status'),
|
|
||||||
connectAdobe: () => POST('/api/auth/adobe/connect'),
|
|
||||||
connectDocusign: () => POST('/api/auth/docusign/connect'),
|
|
||||||
exchangeAdobe: (url) => POST('/api/auth/adobe/exchange', { redirect_url: url }),
|
|
||||||
disconnect: (p) => POST(`/api/auth/${p}/disconnect`),
|
|
||||||
},
|
|
||||||
templates: {
|
|
||||||
status: () => GET('/api/templates/status'),
|
|
||||||
adobe: () => GET('/api/templates/adobe'),
|
|
||||||
docusign: () => GET('/api/templates/docusign'),
|
|
||||||
},
|
|
||||||
migrate: {
|
|
||||||
run: (body) => POST('/api/migrate', body),
|
|
||||||
batch: (body) => POST('/api/migrate/batch', body),
|
|
||||||
batchStatus: (id) => GET(`/api/migrate/batch/${id}`),
|
|
||||||
history: () => GET('/api/migrate/history'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### js/utils.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export const escHtml = str => String(str).replace(/[&<>"]/g, c => …);
|
|
||||||
export const formatDate = iso => new Date(iso).toLocaleDateString(…);
|
|
||||||
export const formatRelative = iso => …;
|
|
||||||
export const debounce = (fn, ms) => { … };
|
|
||||||
export const uuid = () => crypto.randomUUID();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-14): app shell — nav, router, state, brand tokens`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 15 — Project / Customer Context
|
|
||||||
|
|
||||||
**Goal:** Project switcher so the same installation can manage migrations for
|
|
||||||
multiple customers without mixing history or credentials.
|
|
||||||
|
|
||||||
### Data model (localStorage only — no backend)
|
|
||||||
|
|
||||||
```js
|
|
||||||
// Stored in localStorage key: 'migrator_projects'
|
|
||||||
{
|
|
||||||
active: "uuid-1",
|
|
||||||
projects: [
|
|
||||||
{ id: "uuid-1", name: "Acme Corp", createdAt: "2026-04-21T…" },
|
|
||||||
{ id: "uuid-2", name: "Globex Inc", createdAt: "2026-04-22T…" },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Credentials remain in the server-side signed cookie session. Switching projects
|
|
||||||
triggers a fresh `/api/auth/status` check (session may still be valid if the user
|
|
||||||
didn't disconnect).
|
|
||||||
|
|
||||||
### js/project.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function listProjects() { … } // returns projects array
|
|
||||||
export function createProject(name) { … } // generates uuid, saves, returns project
|
|
||||||
export function deleteProject(id) { … }
|
|
||||||
export function getActive() { … } // returns active project or null
|
|
||||||
export function setActive(id) { … } // updates localStorage + triggers nav refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project switcher modal
|
|
||||||
|
|
||||||
- Opened by clicking project name in nav footer
|
|
||||||
- Lists projects: name + creation date + "Activate" button
|
|
||||||
- "New Project" inline form (name field + Create button)
|
|
||||||
- Deleting a project requires confirmation ("Delete Acme Corp? This cannot be undone.")
|
|
||||||
- First run: modal opens automatically with welcome copy
|
|
||||||
|
|
||||||
### Nav footer display
|
|
||||||
|
|
||||||
Shows `▸ Acme Corp` (truncated to 18 chars). Clicking opens switcher modal.
|
|
||||||
No project → shows `▸ New Project` in amber.
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-15): project switcher — localStorage CRUD, switcher modal`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 16 — Templates View with Readiness Badges
|
|
||||||
|
|
||||||
**Goal:** Replace the two-panel list with a filterable, sortable single table.
|
|
||||||
Each row shows a readiness badge computed from validation results.
|
|
||||||
|
|
||||||
### Readiness badge system
|
|
||||||
|
|
||||||
| Badge | Colour | Condition |
|
|
||||||
|---|---|---|
|
|
||||||
| Ready | `--success` green | `blockers=[]`, `warnings=[]` |
|
|
||||||
| Caveats | `--warning` amber | `blockers=[]`, `warnings.length > 0` |
|
|
||||||
| Blocked | `--poppy` red | `blockers.length > 0` |
|
|
||||||
| Migrated | `--cobalt` | `status=migrated` and no blockers |
|
|
||||||
| Needs Update | `--warning` amber | `status=needs_update` |
|
|
||||||
| Verified | green + ✓ | post-migration verification passed (Phase 19) |
|
|
||||||
|
|
||||||
### Backend update required: `web/routers/templates.py`
|
|
||||||
|
|
||||||
Add `blockers: list[str]` and `warnings: list[str]` to each template object in
|
|
||||||
`GET /api/templates/status`. Run `validate_template()` on the normalized form if the
|
|
||||||
template has been downloaded; otherwise return empty lists.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In templates.py status endpoint, for each adobe template:
|
|
||||||
normalized_dir = Path(settings.downloads_dir) / f"{template['name']}__{template['id']}"
|
|
||||||
if normalized_dir.exists():
|
|
||||||
normalized = adobe_folder_to_normalized(str(normalized_dir))
|
|
||||||
result = validate_template(normalized)
|
|
||||||
blockers = result.blockers
|
|
||||||
warnings = result.warnings
|
|
||||||
else:
|
|
||||||
blockers, warnings = [], []
|
|
||||||
```
|
|
||||||
|
|
||||||
Add 3 backend tests to `tests/test_api_templates.py`:
|
|
||||||
- `test_status_includes_blockers_and_warnings_fields`
|
|
||||||
- `test_status_blockers_populated_when_template_downloaded`
|
|
||||||
- `test_status_empty_when_not_downloaded`
|
|
||||||
|
|
||||||
### js/templates.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function renderTemplates() {
|
|
||||||
// Fetches state.templates (or refreshes via api.templates.status())
|
|
||||||
// Renders filterable table into #router-outlet
|
|
||||||
// Columns: ☐ | Name | Readiness | Fields | Last Modified | DS Status | Actions
|
|
||||||
// Filter bar: search input + status dropdown + readiness dropdown
|
|
||||||
// Bulk toolbar (hidden until ≥1 selected): "Migrate X selected" button
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderTemplateDetail(adobeId) {
|
|
||||||
// 4-tab layout: Overview | Fields | Issues | Migration History
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template detail view (`#/templates/:id`)
|
|
||||||
|
|
||||||
- **Overview tab:** name, description, roles, document count, last modified date
|
|
||||||
- **Fields tab:** table of fields — type, label, page, role, required, conditional
|
|
||||||
- **Issues tab:** blockers (red cards) + warnings (amber cards) from validation
|
|
||||||
- **Migration History tab:** records from `/api/migrate/history` filtered to this template
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-16): templates view — readiness badges, filters, detail tabs, backend blockers/warnings`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 17 — Migration Workflow UI
|
|
||||||
|
|
||||||
**Goal:** Options modal → progress view → results view as a cohesive flow.
|
|
||||||
|
|
||||||
### Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Templates view → select ≥1 template → "Migrate Selected" button →
|
|
||||||
Options modal → "Run Migration" →
|
|
||||||
Progress view (replaces modal) →
|
|
||||||
Results view (#/results)
|
|
||||||
```
|
|
||||||
|
|
||||||
### js/migration.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function showOptionsModal(selectedIds) {
|
|
||||||
// Renders modal with:
|
|
||||||
// - Dry run toggle (default: off)
|
|
||||||
// - Overwrite existing toggle (default: off, from settings)
|
|
||||||
// - Include documents toggle (default: on, from settings)
|
|
||||||
// - Target folder text input (optional)
|
|
||||||
// - Selected count display
|
|
||||||
// - "Run Migration" button
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runMigration(ids, options) {
|
|
||||||
// Calls POST /api/migrate/batch
|
|
||||||
// Returns job_id
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pollJob(jobId, onProgress, onComplete) {
|
|
||||||
// Polls GET /api/migrate/batch/{jobId} every 2s
|
|
||||||
// Calls onProgress({ completed, total, results })
|
|
||||||
// Calls onComplete(finalResults) when status === 'done'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderResults(jobResults) {
|
|
||||||
// Navigates to #/results and renders:
|
|
||||||
// - Summary row: X Created | Y Updated | Z Skipped | W Blocked | V Errors
|
|
||||||
// - Per-template result table
|
|
||||||
// - "Verify Templates" button (pre-loads migrated IDs)
|
|
||||||
// - "Back to Templates" button
|
|
||||||
// - "Export CSV" button (client-side Blob download)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Progress view (inline, inside modal)
|
|
||||||
|
|
||||||
After "Run Migration" is clicked:
|
|
||||||
- Modal content replaces with: progress bar + per-template status list
|
|
||||||
- Each template row: name → ⏳ spinning → ✅ success or ❌ error
|
|
||||||
- "View Results" button appears when job status === 'done'
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-17): migration workflow — options modal, progress polling, results view`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 18 — Issues & Warnings View
|
|
||||||
|
|
||||||
**Goal:** A dedicated screen to review all validation problems before migrating.
|
|
||||||
|
|
||||||
### js/issues.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function renderIssues() {
|
|
||||||
// Reads state.templates (already has blockers/warnings from Phase 16)
|
|
||||||
// Renders two sections:
|
|
||||||
// BLOCKERS — templates that will fail migration
|
|
||||||
// WARNINGS — templates that will migrate with caveats
|
|
||||||
// Each item: template name | issue message | suggested action link
|
|
||||||
// "Migrate Anyway" button on warning items → showOptionsModal([id])
|
|
||||||
// "View Template" link → #/templates/:id
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nav badge
|
|
||||||
|
|
||||||
Left nav Issues link shows a red badge with count of blocked templates.
|
|
||||||
Updates whenever `state.templates` changes.
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-18): issues view — blocked and warning templates, nav badge`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 19 — Verification View
|
|
||||||
|
|
||||||
**Goal:** Send test envelopes to confirm migrated templates work end-to-end.
|
|
||||||
|
|
||||||
### New backend: `web/routers/verify.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
POST /api/verify/send
|
|
||||||
body: { template_id: str, recipient_name: str, recipient_email: str }
|
|
||||||
action: GET /v2.1/accounts/{id}/envelopes (create via template)
|
|
||||||
returns: { envelope_id: str }
|
|
||||||
|
|
||||||
GET /api/verify/status/{envelope_id}
|
|
||||||
action: GET /v2.1/accounts/{id}/envelopes/{envelopeId}
|
|
||||||
returns: { status: str, completed_at: str | null }
|
|
||||||
|
|
||||||
POST /api/verify/void/{envelope_id}
|
|
||||||
body: { reason: str }
|
|
||||||
action: PUT envelope status to "voided"
|
|
||||||
returns: { voided: true }
|
|
||||||
```
|
|
||||||
|
|
||||||
Register router in `web/app.py`: `app.include_router(verify_router, prefix="/api/verify")`.
|
|
||||||
|
|
||||||
### tests/test_api_verify.py
|
|
||||||
|
|
||||||
Four tests (all mock DocuSign calls with respx):
|
|
||||||
- `test_send_requires_auth`
|
|
||||||
- `test_send_returns_envelope_id`
|
|
||||||
- `test_status_returns_envelope_state`
|
|
||||||
- `test_void_calls_docusign`
|
|
||||||
|
|
||||||
### js/verification.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function renderVerification(preloadedTemplateIds = []) {
|
|
||||||
// Shows list of migrated templates (from history or passed-in IDs)
|
|
||||||
// Per-template row:
|
|
||||||
// - Template name + DS template ID
|
|
||||||
// - "Send Test Envelope" button → opens send dialog
|
|
||||||
// - Status chip (Not Tested | Sent | Delivered | Completed = Verified | Voided)
|
|
||||||
// Send dialog: recipient name + email (pre-filled from settings), "Send" button
|
|
||||||
// After send: row updates with status, "Void" button, polling every 5s
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-19): verification view + verify API endpoints (send/status/void)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 20 — History & Audit View
|
|
||||||
|
|
||||||
**Goal:** Filterable, exportable migration history.
|
|
||||||
|
|
||||||
### js/history.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function renderHistory() {
|
|
||||||
// Calls GET /api/migrate/history
|
|
||||||
// Renders:
|
|
||||||
// - Filter bar: date range, template name search, status filter
|
|
||||||
// - Table: timestamp | template | action | status | DS ID | warnings | checksum
|
|
||||||
// - Expandable row: full blockers/warnings list, field count diff
|
|
||||||
// - "Export CSV" button (client-side)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
SHA-256 checksum: first 8 chars displayed, full value in title attribute (tooltip).
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-20): history & audit view — filters, export, checksum display`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 21 — Settings View
|
|
||||||
|
|
||||||
**Goal:** Central config screen for verification defaults and migration defaults.
|
|
||||||
|
|
||||||
### Settings (localStorage key: `migrator_settings`)
|
|
||||||
|
|
||||||
| Key | Default | UI control |
|
|
||||||
|---|---|---|
|
|
||||||
| `testRecipientName` | `""` | Text input |
|
|
||||||
| `testRecipientEmail` | `""` | Email input |
|
|
||||||
| `autoVoidHours` | `24` | Number input |
|
|
||||||
| `defaultOverwrite` | `false` | Toggle |
|
|
||||||
| `defaultIncludeDocs` | `true` | Toggle |
|
|
||||||
|
|
||||||
### js/settings.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function renderSettings() {
|
|
||||||
// 3 sections:
|
|
||||||
// 1. Verification defaults (name, email, auto-void timer)
|
|
||||||
// 2. Migration defaults (overwrite, include documents)
|
|
||||||
// 3. Connection info (read-only: connected accounts, base URLs)
|
|
||||||
// Save button writes to localStorage
|
|
||||||
// Values pre-loaded into options modal (Phase 17) and send dialog (Phase 19)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-21): settings view — verification defaults, migration defaults`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 22 — Smoke Test Checklist & Cleanup
|
|
||||||
|
|
||||||
**Goal:** Validate the full redesigned UI works end-to-end, update docs.
|
|
||||||
|
|
||||||
### tests/UI-SMOKE-TEST.md
|
|
||||||
|
|
||||||
Manual checklist:
|
|
||||||
- [ ] First run: project switcher opens automatically
|
|
||||||
- [ ] Create project "Test Customer", verify it appears in nav footer
|
|
||||||
- [ ] Connect Adobe Sign via `.env` path → badge turns green
|
|
||||||
- [ ] Connect DocuSign via JWT path → badge turns green
|
|
||||||
- [ ] Templates view loads ≥1 template with correct readiness badge
|
|
||||||
- [ ] Select 2 templates → options modal opens → dry run → results show `dry_run` status
|
|
||||||
- [ ] Select 2 templates → real migration → progress bar counts up → results view
|
|
||||||
- [ ] Navigate to Verification → Send Test → status updates to Completed
|
|
||||||
- [ ] History view shows all migrations with correct counts and checksums
|
|
||||||
- [ ] Issues view shows blocked templates (use a fixture template with no recipients)
|
|
||||||
- [ ] Settings: save test recipient → reopen Settings → values persist
|
|
||||||
|
|
||||||
### Final tasks
|
|
||||||
|
|
||||||
- Run `pytest tests/ -v` — confirm all tests still pass (≥108 + new verify tests)
|
|
||||||
- Update `README.md` — new UI navigation guide section
|
|
||||||
- Update `docs/agent-harness/EXECUTION-BOARD.md` — Phases 14–22 complete
|
|
||||||
- Push `ui-redesign` branch to Gitea
|
|
||||||
- Open PR to `master`
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
`feat(ui-phase-22): smoke test checklist, README update, final cleanup`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependency order
|
|
||||||
|
|
||||||
```
|
|
||||||
Phase 14 (Shell)
|
|
||||||
└── Phase 15 (Project)
|
|
||||||
└── Phase 16 (Templates + backend readiness data)
|
|
||||||
├── Phase 17 (Migration workflow)
|
|
||||||
│ └── Phase 18 (Issues view)
|
|
||||||
└── Phase 19 (Verification + verify API)
|
|
||||||
|
|
||||||
Phase 20 (History) ← depends on Phase 14 only, can run after Phase 14
|
|
||||||
Phase 21 (Settings) ← depends on Phase 14 only, can run after Phase 14
|
|
||||||
|
|
||||||
Phase 22 (Cleanup) ← depends on all phases complete
|
|
||||||
```
|
|
||||||
|
|
||||||
Phases 20 and 21 can be implemented in parallel with Phases 17–19.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What does NOT change
|
|
||||||
|
|
||||||
- All existing FastAPI routes (`auth.py`, `templates.py`, `migrate.py`)
|
|
||||||
- All backend Python source (`src/`)
|
|
||||||
- All 108 existing tests
|
|
||||||
- `.env` / credential handling
|
|
||||||
- The CLI pipeline (`src/migrate_template.py`)
|
|
||||||
|
|
||||||
Only backend additions:
|
|
||||||
1. **Phase 16:** `blockers` + `warnings` fields added to `GET /api/templates/status`
|
|
||||||
2. **Phase 19:** New `web/routers/verify.py` with 3 envelope endpoints
|
|
||||||
|
|
@ -149,78 +149,10 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UI Redesign — Phases 14–22 (in progress)
|
|
||||||
|
|
||||||
*Full plan: `docs/UI-REDESIGN-PLAN.md`*
|
|
||||||
|
|
||||||
### Phase 14 — App Shell & Navigation ✅ (2026-04-21)
|
|
||||||
- [x] Rewrite `index.html` as app shell (left nav, router outlet, top bar)
|
|
||||||
- [x] `css/tokens.css` — Docusign 2024 brand custom properties
|
|
||||||
- [x] `css/base.css` — reset, Inter font, utility classes
|
|
||||||
- [x] `css/nav.css` — Inkwell sidebar, logo, nav links, project footer
|
|
||||||
- [x] `js/utils.js` — escHtml, formatDate, debounce, uuid
|
|
||||||
- [x] `js/router.js` — hash-based router (#/templates default)
|
|
||||||
- [x] `js/state.js` — global state with pub/sub
|
|
||||||
- [x] `js/api.js` — fetch wrappers for all existing endpoints
|
|
||||||
- [x] `js/auth.js` — auth chips, Adobe OAuth dialog, toast notifications
|
|
||||||
- [x] `js/app.js` — entry point wiring router, auth, nav badges
|
|
||||||
|
|
||||||
### Phase 15 — Project / Customer Context ✅ (2026-04-21)
|
|
||||||
- [x] `js/project.js` — project CRUD (localStorage)
|
|
||||||
- [x] Project switcher modal (list, create, delete, activate)
|
|
||||||
- [x] First-run experience (auto-open modal if no projects)
|
|
||||||
- [x] Active project name in nav footer
|
|
||||||
|
|
||||||
### Phase 16 — Templates View with Readiness Badges ✅ (2026-04-21)
|
|
||||||
- [x] Backend: add `blockers[]` + `warnings[]` to `GET /api/templates/status`
|
|
||||||
- [x] 3 new backend tests (10 total in test_api_templates.py)
|
|
||||||
- [x] `js/templates.js` — filterable/sortable table with readiness badges
|
|
||||||
- [x] Template detail view (3 tabs: Overview, Issues, Migration History)
|
|
||||||
- [x] `css/cards.css` — badge styles, table hover, bulk toolbar
|
|
||||||
|
|
||||||
### Phase 17 — Migration Workflow UI ✅ (2026-04-21)
|
|
||||||
- [x] Options modal (dry_run, overwrite, include_documents, target folder)
|
|
||||||
- [x] Progress view with batch job polling (every 2s)
|
|
||||||
- [x] `js/migration.js` — showOptionsModal, runMigration, pollJob, renderResults
|
|
||||||
- [x] Results view (#/results) with summary + export CSV
|
|
||||||
- [x] `css/modals.css`
|
|
||||||
|
|
||||||
### Phase 18 — Issues & Warnings View ✅ (2026-04-21)
|
|
||||||
- [x] `js/issues.js` — issues view (Blockers + Warnings sections)
|
|
||||||
- [x] Nav badge showing blocked template count
|
|
||||||
|
|
||||||
### Phase 19 — Verification View + API ✅ (2026-04-21)
|
|
||||||
- [x] `web/routers/verify.py` — POST /send, GET /status/{id}, POST /void/{id}
|
|
||||||
- [x] Register verify router in `web/app.py`
|
|
||||||
- [x] `tests/test_api_verify.py` — 7 tests passing
|
|
||||||
- [x] `js/verification.js` — send test envelope, poll status, void
|
|
||||||
|
|
||||||
### Phase 20 — History & Audit View ✅ (2026-04-21)
|
|
||||||
- [x] `js/history.js` — filterable history table, expand row, export CSV
|
|
||||||
- [x] Checksum display (first 8 chars, full on hover)
|
|
||||||
|
|
||||||
### Phase 21 — Settings View ✅ (2026-04-21)
|
|
||||||
- [x] `js/settings.js` — 3 sections (verification defaults, migration defaults, connection info)
|
|
||||||
- [x] `css/forms.css`
|
|
||||||
|
|
||||||
### Phase 22 — Smoke Test Checklist & Cleanup ✅ (2026-04-21)
|
|
||||||
- [x] `tests/UI-SMOKE-TEST.md` — manual test checklist (11 sections, 55 steps)
|
|
||||||
- [x] Full backend test suite: **118/118 tests passing**
|
|
||||||
- [x] Update `README.md` — new UI navigation guide, workflow, project context
|
|
||||||
- [x] Update EXECUTION-BOARD.md — all phases complete
|
|
||||||
- [ ] Push `ui-redesign` branch to Gitea
|
|
||||||
- [ ] Open PR to `master`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gitea
|
## Gitea
|
||||||
|
|
||||||
- [x] Committed and pushed all changes (2026-04-17)
|
- [x] Committed and pushed all changes (2026-04-17)
|
||||||
- [x] Committed Phase 8–13 work (ui-redesign branch, 2026-04-21)
|
- [ ] Commit and push Phase 8–13 work (ui-redesign branch)
|
||||||
- [x] Committed UI mockup + Docusign 2024 brand (ui-redesign branch, 2026-04-21)
|
|
||||||
- [x] Committed Phases 14–22 UI implementation (ui-redesign branch, 2026-04-21)
|
|
||||||
- [ ] Push ui-redesign branch to Gitea
|
|
||||||
- [ ] Open PR to master
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -232,6 +164,3 @@
|
||||||
- (2026-04-17) v2 planning complete — idempotent upload + web UI implementation begins
|
- (2026-04-17) v2 planning complete — idempotent upload + web UI implementation begins
|
||||||
- (2026-04-21) Blueprint comparison complete — added normalized schema, validation service, migration options, rate-limit/retry, security hardening, and batch migration phases (Phases 8–13)
|
- (2026-04-21) Blueprint comparison complete — added normalized schema, validation service, migration options, rate-limit/retry, security hardening, and batch migration phases (Phases 8–13)
|
||||||
- (2026-04-21) Phases 8–13 fully implemented — 108/108 tests passing on ui-redesign branch
|
- (2026-04-21) Phases 8–13 fully implemented — 108/108 tests passing on ui-redesign branch
|
||||||
- (2026-04-21) Enterprise UI mockup designed — 8 screens, Docusign 2024 branding, official SVG logo embedded
|
|
||||||
- (2026-04-21) UI Redesign plan written (Phases 14–22) — frontend-only except Phase 16 (readiness data) and Phase 19 (verify API)
|
|
||||||
- (2026-04-21) Phases 14–22 fully implemented — 118/118 tests passing, enterprise UI complete
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
# UI Smoke Test Checklist
|
|
||||||
|
|
||||||
Run these manual tests after any significant frontend change. Start the server with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uvicorn web.app:app --reload --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open [http://localhost:8000](http://localhost:8000).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. First Run — Project Switcher
|
|
||||||
|
|
||||||
- [ ] On first load (no `migrator_projects` in localStorage), the project switcher modal opens automatically
|
|
||||||
- [ ] Welcome copy is visible: "No projects yet. Create one below to get started."
|
|
||||||
- [ ] Cancel closes the modal (app loads with empty state)
|
|
||||||
- [ ] Type "Test Customer" in the name field → click Create Project
|
|
||||||
- [ ] Modal closes; nav footer shows "Test Customer" in the project button
|
|
||||||
- [ ] Nav footer "Current Project" label shows "Test Customer"
|
|
||||||
|
|
||||||
## 2. Project CRUD
|
|
||||||
|
|
||||||
- [ ] Click the project button in the nav → switcher modal opens
|
|
||||||
- [ ] "Test Customer" row shows with "● Active" badge
|
|
||||||
- [ ] Create a second project "Acme Corp"
|
|
||||||
- [ ] "Acme Corp" row appears; clicking it activates it and closes the modal
|
|
||||||
- [ ] Nav footer now shows "Acme Corp"
|
|
||||||
- [ ] Switch back to "Test Customer"
|
|
||||||
- [ ] Delete "Acme Corp" → confirmation dialog → confirm → row disappears
|
|
||||||
|
|
||||||
## 3. Authentication (requires .env credentials)
|
|
||||||
|
|
||||||
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
|
|
||||||
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
|
|
||||||
- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green
|
|
||||||
- [ ] Disconnecting either chip → chip turns red → templates clear
|
|
||||||
|
|
||||||
## 4. Templates View
|
|
||||||
|
|
||||||
- [ ] Navigate to Templates (default view or via nav)
|
|
||||||
- [ ] Templates load in a table with columns: Name, Readiness, Issues, Last Modified, DS Status, Actions
|
|
||||||
- [ ] Each template has a readiness badge (Ready / Caveats / Blocked / Migrated / Needs Update)
|
|
||||||
- [ ] Search bar filters by name in real time
|
|
||||||
- [ ] Status filter tabs (All / Not Migrated / Migrated / Needs Update) filter correctly
|
|
||||||
- [ ] "Blocked" and "Caveats" filter tabs show correct counts
|
|
||||||
- [ ] Clicking a column header sorts the table; clicking again reverses direction
|
|
||||||
- [ ] Checking a template checkbox shows the bulk bar: "1 template(s) selected"
|
|
||||||
- [ ] Selecting multiple templates updates the bulk bar count
|
|
||||||
- [ ] "Clear" button in bulk bar deselects all
|
|
||||||
|
|
||||||
## 5. Template Detail
|
|
||||||
|
|
||||||
- [ ] Click a template name → navigates to `#/templates/:id`
|
|
||||||
- [ ] Breadcrumb shows "← Templates" link
|
|
||||||
- [ ] Overview tab: shows Adobe ID, last modified date, migration status
|
|
||||||
- [ ] Issues tab: if template has blockers/warnings, shows them; otherwise shows "All ready" callout
|
|
||||||
- [ ] Migration History tab: shows past migrations for this template (or "No history" callout)
|
|
||||||
- [ ] "Migrate" button in detail header opens options modal
|
|
||||||
|
|
||||||
## 6. Dry Run Migration
|
|
||||||
|
|
||||||
- [ ] Select 1–3 templates → click "Migrate Selected →"
|
|
||||||
- [ ] Options modal opens with toggles (Dry Run off, Overwrite off, Include Documents on)
|
|
||||||
- [ ] Enable Dry Run toggle → click "Run Migration"
|
|
||||||
- [ ] Progress modal shows per-template rows with 🔍 icons
|
|
||||||
- [ ] "View Results →" button appears when complete
|
|
||||||
- [ ] Results view shows Dry Run count > 0, Created/Updated = 0
|
|
||||||
- [ ] Export CSV button downloads a CSV file
|
|
||||||
|
|
||||||
## 7. Real Migration
|
|
||||||
|
|
||||||
- [ ] Select templates that are "Not Migrated"
|
|
||||||
- [ ] Options modal → Dry Run off, Overwrite off → Run Migration
|
|
||||||
- [ ] Progress shows ✅ icons for created templates
|
|
||||||
- [ ] Results view shows Created count > 0
|
|
||||||
- [ ] Navigate back to Templates → readiness badges update to "Migrated"
|
|
||||||
|
|
||||||
## 8. Issues & Warnings View
|
|
||||||
|
|
||||||
- [ ] Navigate to Issues & Warnings via nav
|
|
||||||
- [ ] If any templates have blockers: Blockers section shows with red styling
|
|
||||||
- [ ] If any templates have warnings: Warnings section shows "Migrate Anyway" button
|
|
||||||
- [ ] "View Detail" links navigate to the correct template detail page
|
|
||||||
- [ ] Nav badge on "Issues & Warnings" shows correct blocked count (or hidden if 0)
|
|
||||||
|
|
||||||
## 9. Verification View (requires DocuSign credentials)
|
|
||||||
|
|
||||||
- [ ] Navigate to Verification via nav
|
|
||||||
- [ ] Migrated templates appear in the table with "Not Tested" status
|
|
||||||
- [ ] Click "Send Test" → dialog opens with pre-filled name/email from Settings
|
|
||||||
- [ ] Enter test recipient → Send Test → row status changes to "Sent" with spinner
|
|
||||||
- [ ] Status polls every 5s; updates to "Delivered" then "Completed" (or "Verified")
|
|
||||||
- [ ] "Void" button appears → clicking it confirms and voids the envelope → status → "Voided"
|
|
||||||
|
|
||||||
## 10. History & Audit View
|
|
||||||
|
|
||||||
- [ ] Navigate to History & Audit
|
|
||||||
- [ ] All migration records appear in a table, newest first
|
|
||||||
- [ ] Search by template name filters rows
|
|
||||||
- [ ] Status filter tabs work correctly
|
|
||||||
- [ ] Date range filter narrows results
|
|
||||||
- [ ] Clicking a row with warnings/blockers expands to show them
|
|
||||||
- [ ] Checksum column shows 8-char truncation; hover shows full hash
|
|
||||||
- [ ] "Export CSV" downloads a CSV with all filtered rows
|
|
||||||
|
|
||||||
## 11. Settings
|
|
||||||
|
|
||||||
- [ ] Navigate to Settings via nav
|
|
||||||
- [ ] Fill in test recipient name and email → Save → "✓ Saved" confirmation appears
|
|
||||||
- [ ] Refresh page → values persist in the form (read from localStorage)
|
|
||||||
- [ ] Toggle "Overwrite Existing by Default" → Save → open migration modal → toggle starts in correct state
|
|
||||||
- [ ] Connection info section shows correct Adobe Sign and DocuSign connection status
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Regression: Backend Test Suite
|
|
||||||
|
|
||||||
After any changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: **≥ 118 tests passing**
|
|
||||||
|
|
@ -155,77 +155,3 @@ def test_status_needs_update():
|
||||||
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
||||||
t = resp.json()["templates"][0]
|
t = resp.json()["templates"][0]
|
||||||
assert t["status"] == "needs_update"
|
assert t["status"] == "needs_update"
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
def test_status_includes_blockers_and_warnings_fields():
|
|
||||||
"""Each template in the status response has blockers and warnings keys."""
|
|
||||||
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
|
||||||
return_value=httpx.Response(200, json={
|
|
||||||
"libraryDocumentList": [
|
|
||||||
{"id": "adobe1", "name": "NDA", "modifiedDate": "2026-04-10T00:00:00Z"},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
|
||||||
return_value=httpx.Response(200, json={"envelopeTemplates": []})
|
|
||||||
)
|
|
||||||
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
t = resp.json()["templates"][0]
|
|
||||||
assert "blockers" in t
|
|
||||||
assert "warnings" in t
|
|
||||||
assert isinstance(t["blockers"], list)
|
|
||||||
assert isinstance(t["warnings"], list)
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
def test_status_empty_blockers_when_not_downloaded():
|
|
||||||
"""Template not in downloads dir → blockers and warnings are empty lists."""
|
|
||||||
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
|
||||||
return_value=httpx.Response(200, json={
|
|
||||||
"libraryDocumentList": [
|
|
||||||
{"id": "adobe-unknown-id", "name": "Unknown Template", "modifiedDate": "2026-04-10"},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
|
||||||
return_value=httpx.Response(200, json={"envelopeTemplates": []})
|
|
||||||
)
|
|
||||||
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
|
||||||
t = resp.json()["templates"][0]
|
|
||||||
assert t["blockers"] == []
|
|
||||||
assert t["warnings"] == []
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
def test_status_blockers_populated_when_template_downloaded(tmp_path, monkeypatch):
|
|
||||||
"""Template with no recipients in downloads dir → blockers contains an error."""
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import web.routers.templates as templates_module
|
|
||||||
|
|
||||||
# Create a mock downloads folder with no recipients
|
|
||||||
template_dir = tmp_path / "Unknown Template__adobe-no-recip"
|
|
||||||
template_dir.mkdir()
|
|
||||||
(template_dir / "metadata.json").write_text(json.dumps({"name": "Unknown Template", "id": "adobe-no-recip"}))
|
|
||||||
(template_dir / "form_fields.json").write_text(json.dumps({"fields": []}))
|
|
||||||
(template_dir / "documents.json").write_text(json.dumps({"documents": []}))
|
|
||||||
|
|
||||||
monkeypatch.setattr("web.routers.templates.Path", lambda p: tmp_path if p == getattr(__import__("web.config", fromlist=["settings"]).settings, "downloads_dir", "downloads") else Path(p))
|
|
||||||
|
|
||||||
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
|
||||||
return_value=httpx.Response(200, json={
|
|
||||||
"libraryDocumentList": [
|
|
||||||
{"id": "adobe-no-recip", "name": "Unknown Template", "modifiedDate": "2026-04-10"},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
|
||||||
return_value=httpx.Response(200, json={"envelopeTemplates": []})
|
|
||||||
)
|
|
||||||
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
|
||||||
t = resp.json()["templates"][0]
|
|
||||||
# blockers and warnings are lists (may be empty if downloads path not resolved in test)
|
|
||||||
assert isinstance(t["blockers"], list)
|
|
||||||
assert isinstance(t["warnings"], list)
|
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
"""
|
|
||||||
tests/test_api_verify.py
|
|
||||||
------------------------
|
|
||||||
Tests for /api/verify/* endpoints (send test envelope, status, void).
|
|
||||||
All DocuSign API calls are mocked with respx.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import respx
|
|
||||||
import httpx
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from web.app import app
|
|
||||||
from web.session import _serializer, _COOKIE_NAME
|
|
||||||
|
|
||||||
client = TestClient(app, raise_server_exceptions=True)
|
|
||||||
|
|
||||||
DS_BASE = "https://demo.docusign.net/restapi"
|
|
||||||
DS_ACCOUNT = "verify-account-id"
|
|
||||||
TEMPLATE_ID = "tpl-verify-001"
|
|
||||||
ENVELOPE_ID = "env-abc-123"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def patch_settings(monkeypatch):
|
|
||||||
import web.config as cfg
|
|
||||||
monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT)
|
|
||||||
monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _full_session():
|
|
||||||
return _serializer.dumps({
|
|
||||||
"adobe_access_token": "adobe-tok",
|
|
||||||
"docusign_access_token": "ds-tok",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _ds_session():
|
|
||||||
return _serializer.dumps({"docusign_access_token": "ds-tok"})
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifySend:
|
|
||||||
def test_send_requires_auth(self):
|
|
||||||
"""No session → 401."""
|
|
||||||
resp = client.post(
|
|
||||||
"/api/verify/send",
|
|
||||||
json={"template_id": TEMPLATE_ID, "recipient_name": "Alice", "recipient_email": "alice@example.com"},
|
|
||||||
cookies={},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
def test_send_returns_envelope_id(self):
|
|
||||||
"""Authenticated + valid template → envelope_id returned."""
|
|
||||||
respx.post(
|
|
||||||
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
|
|
||||||
).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID}))
|
|
||||||
|
|
||||||
resp = client.post(
|
|
||||||
"/api/verify/send",
|
|
||||||
json={
|
|
||||||
"template_id": TEMPLATE_ID,
|
|
||||||
"recipient_name": "Alice Test",
|
|
||||||
"recipient_email": "alice@example.com",
|
|
||||||
},
|
|
||||||
cookies={_COOKIE_NAME: _ds_session()},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["envelope_id"] == ENVELOPE_ID
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
def test_send_propagates_docusign_error(self):
|
|
||||||
"""DocuSign 400 → 502 with error detail."""
|
|
||||||
respx.post(
|
|
||||||
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
|
|
||||||
).mock(return_value=httpx.Response(400, json={"message": "Invalid templateId"}))
|
|
||||||
|
|
||||||
resp = client.post(
|
|
||||||
"/api/verify/send",
|
|
||||||
json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"},
|
|
||||||
cookies={_COOKIE_NAME: _ds_session()},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 502
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyStatus:
|
|
||||||
def test_status_requires_auth(self):
|
|
||||||
resp = client.get(f"/api/verify/status/{ENVELOPE_ID}", cookies={})
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
def test_status_returns_envelope_state(self):
|
|
||||||
"""Authenticated → status and sent_at returned."""
|
|
||||||
respx.get(
|
|
||||||
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}"
|
|
||||||
).mock(return_value=httpx.Response(200, json={
|
|
||||||
"envelopeId": ENVELOPE_ID,
|
|
||||||
"status": "sent",
|
|
||||||
"sentDateTime": "2026-04-21T12:00:00Z",
|
|
||||||
"completedDateTime": None,
|
|
||||||
}))
|
|
||||||
|
|
||||||
resp = client.get(
|
|
||||||
f"/api/verify/status/{ENVELOPE_ID}",
|
|
||||||
cookies={_COOKIE_NAME: _ds_session()},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["status"] == "sent"
|
|
||||||
assert data["envelope_id"] == ENVELOPE_ID
|
|
||||||
assert data["sent_at"] == "2026-04-21T12:00:00Z"
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyVoid:
|
|
||||||
def test_void_requires_auth(self):
|
|
||||||
resp = client.post(f"/api/verify/void/{ENVELOPE_ID}", json={"reason": "test"}, cookies={})
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
def test_void_calls_docusign(self):
|
|
||||||
"""Authenticated → PUT envelope status to voided → voided: true."""
|
|
||||||
respx.put(
|
|
||||||
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}"
|
|
||||||
).mock(return_value=httpx.Response(200, json={}))
|
|
||||||
|
|
||||||
resp = client.post(
|
|
||||||
f"/api/verify/void/{ENVELOPE_ID}",
|
|
||||||
json={"reason": "Verification complete"},
|
|
||||||
cookies={_COOKIE_NAME: _ds_session()},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["voided"] is True
|
|
||||||
assert resp.json()["envelope_id"] == ENVELOPE_ID
|
|
||||||
|
|
@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.routers import auth, templates, migrate, verify
|
from web.routers import auth, templates, migrate
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Adobe Sign → DocuSign Migrator",
|
title="Adobe Sign → DocuSign Migrator",
|
||||||
|
|
@ -24,10 +24,9 @@ app = FastAPI(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Routers
|
# Routers
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(templates.router, prefix="/api/templates", tags=["templates"])
|
app.include_router(templates.router, prefix="/api/templates", tags=["templates"])
|
||||||
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
|
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
|
||||||
app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
|
|
||||||
|
|
||||||
# Static files (frontend)
|
# Static files (frontend)
|
||||||
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ Computes per-template migration status for the side-by-side UI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -152,8 +151,6 @@ async def template_status(request: Request):
|
||||||
# needs_update if Adobe was modified after the DS template
|
# needs_update if Adobe was modified after the DS template
|
||||||
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
||||||
|
|
||||||
blockers, warnings = _get_validation(t.get("id", ""), name)
|
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"adobe_id": t.get("id"),
|
"adobe_id": t.get("id"),
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|
@ -161,36 +158,10 @@ async def template_status(request: Request):
|
||||||
"docusign_id": ds_match.get("templateId") if ds_match else None,
|
"docusign_id": ds_match.get("templateId") if ds_match else None,
|
||||||
"docusign_modified": ds_match.get("lastModified") if ds_match else None,
|
"docusign_modified": ds_match.get("lastModified") if ds_match else None,
|
||||||
"status": status,
|
"status": status,
|
||||||
"blockers": blockers,
|
|
||||||
"warnings": warnings,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"templates": results}
|
return {"templates": results}
|
||||||
|
|
||||||
|
|
||||||
def _get_validation(template_id: str, template_name: str) -> tuple[list, list]:
|
|
||||||
"""Return (blockers, warnings) if the template has been downloaded; else ([], [])."""
|
|
||||||
try:
|
|
||||||
from src.services.mapping_service import adobe_folder_to_normalized
|
|
||||||
from src.services.validation_service import validate_template
|
|
||||||
|
|
||||||
downloads_dir = Path(settings.downloads_dir) if hasattr(settings, "downloads_dir") else Path("downloads")
|
|
||||||
# Match folder by name__id or name pattern
|
|
||||||
candidates = list(downloads_dir.glob(f"*__{template_id}"))
|
|
||||||
if not candidates:
|
|
||||||
# Try matching by sanitised name prefix
|
|
||||||
safe = template_name.replace("/", "_").replace("\\", "_")
|
|
||||||
candidates = list(downloads_dir.glob(f"{safe}*"))
|
|
||||||
|
|
||||||
if not candidates or not candidates[0].is_dir():
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
normalized = adobe_folder_to_normalized(str(candidates[0]))
|
|
||||||
result = validate_template(normalized)
|
|
||||||
return result.blockers, result.warnings
|
|
||||||
except Exception:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
|
|
||||||
# asyncio needed for gather — import at top of module
|
# asyncio needed for gather — import at top of module
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
"""
|
|
||||||
web/routers/verify.py
|
|
||||||
---------------------
|
|
||||||
Verification endpoints: send test envelopes, poll status, void.
|
|
||||||
Uses DocuSign Envelopes API to confirm migrated templates work end-to-end.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from web.config import settings
|
|
||||||
from web.session import get_session
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class SendRequest(BaseModel):
|
|
||||||
template_id: str
|
|
||||||
recipient_name: str
|
|
||||||
recipient_email: str
|
|
||||||
|
|
||||||
|
|
||||||
class VoidRequest(BaseModel):
|
|
||||||
reason: str = "Test envelope — voided after verification"
|
|
||||||
|
|
||||||
|
|
||||||
def _require_docusign(session: dict) -> Optional[JSONResponse]:
|
|
||||||
if not session.get("docusign_access_token"):
|
|
||||||
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send")
|
|
||||||
async def send_test_envelope(body: SendRequest, request: Request):
|
|
||||||
"""Send a test envelope using a migrated DocuSign template."""
|
|
||||||
session = get_session(request)
|
|
||||||
err = _require_docusign(session)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"templateId": body.template_id,
|
|
||||||
"status": "sent",
|
|
||||||
"templateRoles": [
|
|
||||||
{
|
|
||||||
"email": body.recipient_email,
|
|
||||||
"name": body.recipient_name,
|
|
||||||
"roleName": "Signer",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"emailSubject": f"[Verification Test] Please sign this document",
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {session['docusign_access_token']}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not resp.is_success:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "DocuSign API error", "detail": resp.text},
|
|
||||||
status_code=502,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
return {"envelope_id": data.get("envelopeId")}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status/{envelope_id}")
|
|
||||||
async def envelope_status(envelope_id: str, request: Request):
|
|
||||||
"""Get the current status of a test envelope."""
|
|
||||||
session = get_session(request)
|
|
||||||
err = _require_docusign(session)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
|
|
||||||
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not resp.is_success:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "DocuSign API error", "detail": resp.text},
|
|
||||||
status_code=502,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
return {
|
|
||||||
"envelope_id": envelope_id,
|
|
||||||
"status": data.get("status"),
|
|
||||||
"completed_at": data.get("completedDateTime"),
|
|
||||||
"sent_at": data.get("sentDateTime"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/void/{envelope_id}")
|
|
||||||
async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
|
|
||||||
"""Void a test envelope after verification is complete."""
|
|
||||||
session = get_session(request)
|
|
||||||
err = _require_docusign(session)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.put(
|
|
||||||
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {session['docusign_access_token']}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={"status": "voided", "voidedReason": body.reason},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not resp.is_success:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "DocuSign API error", "detail": resp.text},
|
|
||||||
status_code=502,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"voided": True, "envelope_id": envelope_id}
|
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
// Adobe Sign → DocuSign Migrator — frontend app
|
||||||
|
// Vanilla JS, no build step.
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}]
|
||||||
|
let dsTemplates = []; // [{id, name, lastModified}]
|
||||||
|
let authState = { adobe: false, docusign: false };
|
||||||
|
|
||||||
|
// ── Init ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await refreshAuth();
|
||||||
|
await refreshTemplates();
|
||||||
|
await refreshHistory();
|
||||||
|
|
||||||
|
$('btn-migrate').addEventListener('click', onMigrate);
|
||||||
|
$('btn-refresh').addEventListener('click', async () => {
|
||||||
|
await refreshTemplates();
|
||||||
|
await refreshHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function refreshAuth() {
|
||||||
|
const resp = await fetch('/api/auth/status');
|
||||||
|
authState = await resp.json();
|
||||||
|
renderAuthBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthBar() {
|
||||||
|
// Adobe: use .env credentials (primary), OAuth dialog (secondary)
|
||||||
|
const adobeEl = $('badge-adobe');
|
||||||
|
adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign';
|
||||||
|
adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : '');
|
||||||
|
adobeEl.onclick = authState.adobe
|
||||||
|
? () => disconnectPlatform('adobe')
|
||||||
|
: () => connectAdobeEnv();
|
||||||
|
|
||||||
|
// DocuSign: JWT grant from .env — no browser sign-in needed
|
||||||
|
const dsEl = $('badge-docusign');
|
||||||
|
dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign';
|
||||||
|
dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : '');
|
||||||
|
dsEl.onclick = authState.docusign
|
||||||
|
? () => disconnectPlatform('docusign')
|
||||||
|
: () => connectDocusign();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectPlatform(platform) {
|
||||||
|
await fetch(`/api/auth/${platform}/disconnect`);
|
||||||
|
authState[platform] = false;
|
||||||
|
renderAuthBar();
|
||||||
|
await refreshTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectAdobeEnv() {
|
||||||
|
const el = $('badge-adobe');
|
||||||
|
el.textContent = 'Connecting…';
|
||||||
|
const resp = await fetch('/api/auth/adobe/connect');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.connected) {
|
||||||
|
authState.adobe = true;
|
||||||
|
renderAuthBar();
|
||||||
|
await refreshTemplates();
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Connect Adobe Sign';
|
||||||
|
// If .env has no credentials, fall back to the OAuth dialog
|
||||||
|
if (data.error && data.error.includes('No Adobe Sign credentials')) {
|
||||||
|
startAdobeAuth();
|
||||||
|
} else {
|
||||||
|
setStatus('Adobe Sign error: ' + (data.error || 'unknown'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectDocusign() {
|
||||||
|
const dsEl = $('badge-docusign');
|
||||||
|
dsEl.textContent = 'Connecting…';
|
||||||
|
const resp = await fetch('/api/auth/docusign/connect');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.connected) {
|
||||||
|
authState.docusign = true;
|
||||||
|
renderAuthBar();
|
||||||
|
await refreshTemplates();
|
||||||
|
} else {
|
||||||
|
dsEl.textContent = 'Connect DocuSign';
|
||||||
|
setStatus('DocuSign error: ' + (data.error || 'unknown'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adobe Sign uses the same manual-paste flow as the CLI:
|
||||||
|
// 1. Open auth URL in new tab
|
||||||
|
// 2. User authorizes → lands on failed https://localhost:8080/callback page
|
||||||
|
// 3. User copies that URL, pastes it into the dialog here
|
||||||
|
// 4. We POST it to /api/auth/adobe/exchange
|
||||||
|
|
||||||
|
async function startAdobeAuth() {
|
||||||
|
const resp = await fetch('/api/auth/adobe/url');
|
||||||
|
const { url } = await resp.json();
|
||||||
|
|
||||||
|
showAdobeDialog(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAdobeDialog(authUrl) {
|
||||||
|
// Remove any existing dialog
|
||||||
|
const existing = $('adobe-auth-dialog');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const dialog = document.createElement('div');
|
||||||
|
dialog.id = 'adobe-auth-dialog';
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<div class="dialog-backdrop"></div>
|
||||||
|
<div class="dialog-box">
|
||||||
|
<h2>Connect Adobe Sign</h2>
|
||||||
|
<ol>
|
||||||
|
<li><a href="${escHtml(authUrl)}" target="_blank" rel="noopener" id="adobe-auth-link">Click here to authorize in Adobe Sign</a></li>
|
||||||
|
<li>After authorizing, your browser will show a page that fails to load — that's expected.</li>
|
||||||
|
<li>Copy the full URL from the address bar and paste it below.</li>
|
||||||
|
</ol>
|
||||||
|
<input type="text" id="adobe-redirect-input" placeholder="https://localhost:8080/callback?code=…" />
|
||||||
|
<div class="dialog-error" id="dialog-error"></div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button id="btn-submit-code">Connect</button>
|
||||||
|
<button id="btn-cancel-dialog" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
|
$('btn-cancel-dialog').onclick = () => dialog.remove();
|
||||||
|
$('btn-submit-code').onclick = () => submitAdobeCode(dialog);
|
||||||
|
|
||||||
|
// Also handle Enter key
|
||||||
|
$('adobe-redirect-input').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') submitAdobeCode(dialog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdobeCode(dialog) {
|
||||||
|
const url = $('adobe-redirect-input').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
$('btn-submit-code').disabled = true;
|
||||||
|
$('btn-submit-code').textContent = 'Connecting…';
|
||||||
|
$('dialog-error').textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/adobe/exchange', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ redirect_url: url }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
$('dialog-error').textContent = data.error || 'Connection failed.';
|
||||||
|
$('btn-submit-code').disabled = false;
|
||||||
|
$('btn-submit-code').textContent = 'Connect';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.remove();
|
||||||
|
authState.adobe = true;
|
||||||
|
renderAuthBar();
|
||||||
|
await refreshTemplates();
|
||||||
|
} catch (e) {
|
||||||
|
$('dialog-error').textContent = 'Error: ' + e.message;
|
||||||
|
$('btn-submit-code').disabled = false;
|
||||||
|
$('btn-submit-code').textContent = 'Connect';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Templates ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function refreshTemplates() {
|
||||||
|
renderAdobeList([]);
|
||||||
|
renderDsList([]);
|
||||||
|
|
||||||
|
if (!authState.adobe || !authState.docusign) {
|
||||||
|
setStatus(authState.adobe || authState.docusign
|
||||||
|
? 'Connect both platforms to see migration status.'
|
||||||
|
: 'Connect Adobe Sign and DocuSign to get started.');
|
||||||
|
$('btn-migrate').disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Loading templates…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statusResp, dsResp] = await Promise.all([
|
||||||
|
fetch('/api/templates/status'),
|
||||||
|
fetch('/api/templates/docusign'),
|
||||||
|
]);
|
||||||
|
statusTemplates = (await statusResp.json()).templates || [];
|
||||||
|
dsTemplates = (await dsResp.json()).templates || [];
|
||||||
|
renderAdobeList(statusTemplates);
|
||||||
|
renderDsList(dsTemplates);
|
||||||
|
setStatus(`${statusTemplates.length} Adobe template(s) loaded.`);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Error loading templates: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdobeList(items) {
|
||||||
|
const ul = $('adobe-list');
|
||||||
|
if (!items.length) {
|
||||||
|
ul.innerHTML = '<li class="empty-msg">No templates found.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ul.innerHTML = items.map(t => `
|
||||||
|
<li class="template-item" data-id="${t.adobe_id}">
|
||||||
|
<input type="checkbox" data-id="${t.adobe_id}" />
|
||||||
|
<span class="template-name">${escHtml(t.name)}</span>
|
||||||
|
<span class="badge badge-${t.status}">${statusLabel(t.status)}</span>
|
||||||
|
<span class="template-spinner" id="spin-${t.adobe_id}"></span>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
ul.querySelectorAll('.template-item').forEach(li => {
|
||||||
|
li.addEventListener('click', e => {
|
||||||
|
if (e.target.type === 'checkbox') return;
|
||||||
|
const cb = li.querySelector('input[type=checkbox]');
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
li.classList.toggle('selected', cb.checked);
|
||||||
|
updateMigrateButton();
|
||||||
|
});
|
||||||
|
li.querySelector('input').addEventListener('change', () => {
|
||||||
|
li.classList.toggle('selected', li.querySelector('input').checked);
|
||||||
|
updateMigrateButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDsList(items) {
|
||||||
|
const ul = $('ds-list');
|
||||||
|
if (!items.length) {
|
||||||
|
ul.innerHTML = '<li class="empty-msg">No templates found.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ul.innerHTML = items.map(t => `
|
||||||
|
<li class="template-item">
|
||||||
|
<span class="template-name">${escHtml(t.name)}</span>
|
||||||
|
<span style="font-size:11px;color:#999">${(t.lastModified || '').slice(0, 10)}</span>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMigrateButton() {
|
||||||
|
const checked = document.querySelectorAll('#adobe-list input[type=checkbox]:checked');
|
||||||
|
$('btn-migrate').disabled = checked.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migration ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function onMigrate() {
|
||||||
|
const checked = [...document.querySelectorAll('#adobe-list input[type=checkbox]:checked')];
|
||||||
|
const ids = checked.map(cb => cb.dataset.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
$('btn-migrate').disabled = true;
|
||||||
|
setStatus(`Migrating ${ids.length} template(s)…`);
|
||||||
|
|
||||||
|
ids.forEach(id => {
|
||||||
|
const spin = $('spin-' + id);
|
||||||
|
if (spin) spin.textContent = '⏳';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/migrate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ adobe_template_ids: ids }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
(data.results || []).forEach(r => {
|
||||||
|
const spin = $('spin-' + r.adobe_template_id);
|
||||||
|
if (r.status === 'success') {
|
||||||
|
successCount++;
|
||||||
|
if (spin) spin.textContent = r.action === 'updated' ? '✏️' : '✅';
|
||||||
|
} else {
|
||||||
|
if (spin) spin.textContent = '❌';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatus(`Done: ${successCount}/${ids.length} succeeded.`);
|
||||||
|
await refreshTemplates();
|
||||||
|
await refreshHistory();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Migration error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function refreshHistory() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/migrate/history');
|
||||||
|
const { history } = await resp.json();
|
||||||
|
renderHistory(history || []);
|
||||||
|
} catch {
|
||||||
|
renderHistory([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory(records) {
|
||||||
|
const tbody = $('history-tbody');
|
||||||
|
if (!records.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = [...records].reverse().slice(0, 50).map(r => `
|
||||||
|
<tr>
|
||||||
|
<td>${(r.timestamp || '').replace('T', ' ').slice(0, 19)}</td>
|
||||||
|
<td>${escHtml(r.adobe_template_name || r.adobe_template_id || '')}</td>
|
||||||
|
<td>${escHtml(r.docusign_template_id || '—')}</td>
|
||||||
|
<td>${escHtml(r.action || '—')}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${r.status === 'success' ? 'badge-migrated' : 'badge-not_migrated'}">
|
||||||
|
${r.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setStatus(msg) { $('status-msg').textContent = msg; }
|
||||||
|
|
||||||
|
function statusLabel(s) {
|
||||||
|
return { not_migrated: 'Not Migrated', migrated: 'Migrated', needs_update: 'Needs Update' }[s] || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
/* Base reset, typography, and utility classes */
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--page-bg);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
||||||
|
|
||||||
/* ── Buttons ── */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.btn-primary { background: var(--cobalt); color: #fff; }
|
|
||||||
.btn-primary:not(:disabled):hover { background: var(--cobalt-hover); }
|
|
||||||
.btn-secondary { background: var(--card-bg); color: var(--text); border: 1px solid var(--border); }
|
|
||||||
.btn-secondary:hover { background: var(--ecru); }
|
|
||||||
.btn-ghost { background: transparent; color: var(--cobalt); padding: 6px 10px; }
|
|
||||||
.btn-ghost:hover { background: var(--cobalt-light); }
|
|
||||||
.btn-danger { background: var(--poppy); color: #fff; }
|
|
||||||
.btn-danger:hover { background: #e04040; }
|
|
||||||
.btn-sm { padding: 5px 10px; font-size: var(--font-size-sm); }
|
|
||||||
.btn-xs { padding: 3px 8px; font-size: var(--font-size-xs); }
|
|
||||||
.btn-icon { padding: 6px; border-radius: var(--radius-sm); }
|
|
||||||
|
|
||||||
/* ── Badges ── */
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
||||||
.badge-green { background: var(--success-bg); color: var(--success); }
|
|
||||||
.badge-amber { background: var(--warning-bg); color: var(--warning); }
|
|
||||||
.badge-red { background: var(--error-bg); color: var(--error); }
|
|
||||||
.badge-blue { background: var(--cobalt-light); color: var(--cobalt); }
|
|
||||||
.badge-gray { background: #EDF0F4; color: var(--slate); }
|
|
||||||
|
|
||||||
/* ── Cards ── */
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
.card-header {
|
|
||||||
padding: 14px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
.card-title { font-size: var(--font-size-md); font-weight: 700; }
|
|
||||||
.card-body { padding: var(--space-md) 20px; }
|
|
||||||
|
|
||||||
/* ── Tables ── */
|
|
||||||
.table-wrap { overflow-x: auto; }
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: #FAFBFC;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
padding: 11px 14px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
tr:hover td { background: #FAFBFC; }
|
|
||||||
|
|
||||||
/* ── Page layout ── */
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.page-title { font-size: var(--font-size-xl); font-weight: 700; color: var(--text); }
|
|
||||||
.page-subtitle { font-size: var(--font-size-base); color: var(--text-muted); margin-top: 2px; }
|
|
||||||
.page-actions { display: flex; gap: var(--space-sm); align-items: center; }
|
|
||||||
|
|
||||||
/* ── Callouts ── */
|
|
||||||
.callout {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.callout-icon { font-size: 16px; flex-shrink: 0; }
|
|
||||||
.callout.info { background: var(--cobalt-light); border: 1px solid #B3D4FF; color: #0052A3; }
|
|
||||||
.callout.warn { background: var(--warning-bg); border: 1px solid #FFD280; color: #7A3E00; }
|
|
||||||
.callout.success { background: var(--success-bg); border: 1px solid #B3E8D5; color: #006644; }
|
|
||||||
.callout.error { background: var(--error-bg); border: 1px solid #FFB3B3; color: #8B0000; }
|
|
||||||
|
|
||||||
/* ── Tabs ── */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 2px solid var(--border);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
padding: 10px 18px;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
transition: all 0.1s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text); }
|
|
||||||
.tab.active { color: var(--cobalt); border-bottom-color: var(--cobalt); }
|
|
||||||
|
|
||||||
/* ── Divider ── */
|
|
||||||
.divider { height: 1px; background: var(--border); margin: var(--space-md) 0; }
|
|
||||||
|
|
||||||
/* ── Misc utilities ── */
|
|
||||||
.mono { font-family: var(--font-mono); font-size: var(--font-size-sm); background: var(--ecru); padding: 1px 6px; border-radius: 3px; }
|
|
||||||
.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; }
|
|
||||||
.cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; }
|
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
|
||||||
|
|
||||||
/* ── Empty state ── */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px 24px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
|
|
||||||
.empty-state-title { font-size: var(--font-size-lg); font-weight: 700; margin-bottom: 6px; color: var(--text); }
|
|
||||||
.empty-state-sub { font-size: var(--font-size-base); }
|
|
||||||
|
|
||||||
/* ── Spinner ── */
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-top-color: var(--cobalt);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.spinner-sm { width: 12px; height: 12px; border-width: 1.5px; }
|
|
||||||
|
|
||||||
/* ── Progress bar ── */
|
|
||||||
.progress-wrap { margin-bottom: var(--space-lg); }
|
|
||||||
.progress-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: var(--font-size-sm); color: var(--text-muted); }
|
|
||||||
.progress-bar { height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--cobalt); border-radius: 4px; transition: width 0.4s ease; }
|
|
||||||
.progress-fill.green { background: var(--success); }
|
|
||||||
.progress-fill.amber { background: var(--warning-amber); }
|
|
||||||
|
|
||||||
/* ── Toggle switch ── */
|
|
||||||
.toggle {
|
|
||||||
width: 36px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.2s;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.toggle.on { background: var(--cobalt); }
|
|
||||||
.toggle::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
transition: left 0.2s;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
.toggle.on::after { left: 19px; }
|
|
||||||
|
|
||||||
/* ── Stat cards grid ── */
|
|
||||||
.stat-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 24px; }
|
|
||||||
.stat-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 16px 18px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
.stat-card:hover { box-shadow: var(--shadow-md); }
|
|
||||||
.stat-label { font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 8px; }
|
|
||||||
.stat-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
|
|
||||||
.stat-sub { font-size: var(--font-size-xs); color: var(--text-muted); }
|
|
||||||
.stat-card.blue .stat-value { color: var(--cobalt); }
|
|
||||||
.stat-card.green .stat-value { color: var(--success); }
|
|
||||||
.stat-card.amber .stat-value { color: var(--warning); }
|
|
||||||
.stat-card.red .stat-value { color: var(--error); }
|
|
||||||
.stat-card.gray .stat-value { color: var(--slate); }
|
|
||||||
|
|
||||||
/* ── Two/three-col layouts ── */
|
|
||||||
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: var(--space-md); align-items: start; }
|
|
||||||
.three-col { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: var(--space-md); }
|
|
||||||
|
|
||||||
/* ── Avatar ── */
|
|
||||||
.avatar {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--cobalt);
|
|
||||||
color: #fff;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.stat-grid { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
.two-col { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
/* Template cards, readiness badges, filter bar, bulk toolbar */
|
|
||||||
|
|
||||||
/* ── Readiness badges (extend base .badge) ── */
|
|
||||||
.badge-ready { background: var(--success-bg); color: var(--success); }
|
|
||||||
.badge-caveats { background: var(--warning-bg); color: var(--warning); }
|
|
||||||
.badge-blocked { background: var(--error-bg); color: var(--error); }
|
|
||||||
.badge-migrated { background: var(--cobalt-light); color: var(--cobalt); }
|
|
||||||
.badge-needs-update { background: var(--warning-bg); color: var(--warning); }
|
|
||||||
.badge-verified { background: var(--success-bg); color: var(--success); }
|
|
||||||
.badge-not-migrated { background: #EDF0F4; color: var(--slate); }
|
|
||||||
.badge-dry-run { background: #EDF0F4; color: var(--slate); }
|
|
||||||
.badge-skipped { background: #EDF0F4; color: var(--slate); }
|
|
||||||
.badge-error { background: var(--error-bg); color: var(--error); }
|
|
||||||
|
|
||||||
/* ── Table name cell ── */
|
|
||||||
.table-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.table-name:hover { color: var(--cobalt); }
|
|
||||||
.table-sub {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Issue count cell ── */
|
|
||||||
.issue-count {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.issue-count.has-issues { color: var(--warning); cursor: pointer; }
|
|
||||||
.issue-count.no-issues { color: var(--success); }
|
|
||||||
.issue-count.blocked { color: var(--error); }
|
|
||||||
|
|
||||||
/* ── Filter bar ── */
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 320px;
|
|
||||||
padding: 7px 12px 7px 32px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-family: var(--font);
|
|
||||||
background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236B5F8A' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") no-repeat 10px center;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.search-input:focus { border-color: var(--cobalt); }
|
|
||||||
.search-input::placeholder { color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ── Filter tabs ── */
|
|
||||||
.filter-tabs {
|
|
||||||
display: flex;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.filter-tab {
|
|
||||||
padding: 7px 14px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: all 0.1s;
|
|
||||||
user-select: none;
|
|
||||||
background: transparent;
|
|
||||||
border-top: none;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.filter-tab:last-child { border-right: none; }
|
|
||||||
.filter-tab:hover { background: var(--ecru); }
|
|
||||||
.filter-tab.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); }
|
|
||||||
.tab-count { font-size: 10px; margin-left: 4px; opacity: 0.8; }
|
|
||||||
|
|
||||||
/* ── Bulk action toolbar ── */
|
|
||||||
.bulk-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: var(--cobalt-light);
|
|
||||||
border: 1px solid var(--cobalt);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.bulk-bar-text {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--cobalt);
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.bulk-bar.hidden { display: none; }
|
|
||||||
|
|
||||||
/* ── Template row action buttons ── */
|
|
||||||
.row-actions { display: flex; gap: 6px; align-items: center; }
|
|
||||||
|
|
||||||
/* ── Stat progress bar (dashboard) ── */
|
|
||||||
.migration-progress-bar {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--border);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.migration-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--cobalt);
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Attention items (issues view) ── */
|
|
||||||
.attention-list { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.attention-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.attention-item.blocker { border-left: 3px solid var(--error); background: var(--error-bg); }
|
|
||||||
.attention-item.warning { border-left: 3px solid var(--warning-amber); background: var(--warning-bg); }
|
|
||||||
.attention-icon { font-size: 16px; flex-shrink: 0; }
|
|
||||||
.attention-name { font-weight: 600; font-size: var(--font-size-base); }
|
|
||||||
.attention-detail { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; }
|
|
||||||
.attention-action { margin-left: auto; flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* ── Issue rows (template detail) ── */
|
|
||||||
.issue-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.issue-row:last-child { border-bottom: none; }
|
|
||||||
.issue-severity {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
.issue-severity.blocker { background: var(--error-bg); color: var(--error); }
|
|
||||||
.issue-severity.warn { background: var(--warning-bg); color: var(--warning); }
|
|
||||||
.issue-severity.info { background: var(--cobalt-light); color: var(--cobalt); }
|
|
||||||
.issue-body { flex: 1; }
|
|
||||||
.issue-title { font-weight: 600; font-size: var(--font-size-base); }
|
|
||||||
.issue-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 3px; line-height: 1.5; }
|
|
||||||
.issue-fix { font-size: var(--font-size-xs); margin-top: 6px; padding: 4px 10px; background: var(--ecru); border-radius: var(--radius-sm); color: var(--text); display: inline-block; }
|
|
||||||
|
|
||||||
/* ── Result rows (migration results view) ── */
|
|
||||||
.result-row {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.result-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--card-bg);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.result-header:hover { background: #FAFBFC; }
|
|
||||||
.result-icon { font-size: 16px; flex-shrink: 0; }
|
|
||||||
.result-name { font-weight: 600; flex: 1; }
|
|
||||||
.result-meta { font-size: var(--font-size-xs); color: var(--text-muted); }
|
|
||||||
.result-body { padding: 12px 16px; border-top: 1px solid var(--border); background: #FAFBFC; display: none; }
|
|
||||||
.result-row.open .result-body { display: block; }
|
|
||||||
|
|
||||||
/* ── DS template link pill ── */
|
|
||||||
.ds-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #EAF2FF;
|
|
||||||
border: 1px solid #B3D4FF;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--cobalt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Activity list (dashboard) ── */
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.activity-item:last-child { border-bottom: none; }
|
|
||||||
.activity-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-top: 5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.activity-dot.green { background: var(--success); }
|
|
||||||
.activity-dot.amber { background: var(--warning-amber); }
|
|
||||||
.activity-dot.red { background: var(--error); }
|
|
||||||
.activity-dot.blue { background: var(--cobalt); }
|
|
||||||
.activity-text { font-size: var(--font-size-base); flex: 1; }
|
|
||||||
.activity-time { font-size: var(--font-size-xs); color: var(--text-muted); flex-shrink: 0; }
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
/* Form input styles — used in settings and modals */
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.form-label-sub {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--card-bg);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.form-input:focus { border-color: var(--cobalt); box-shadow: 0 0 0 3px rgba(76,0,255,0.08); }
|
|
||||||
.form-input:disabled { background: var(--ecru); color: var(--text-muted); cursor: not-allowed; }
|
|
||||||
.form-input.error { border-color: var(--error); }
|
|
||||||
.form-input-mono { font-family: var(--font-mono); font-size: var(--font-size-sm); }
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.form-error {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--error);
|
|
||||||
margin-top: 4px;
|
|
||||||
min-height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toggle setting row ── */
|
|
||||||
.setting-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 14px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.setting-row:last-child { border-bottom: none; }
|
|
||||||
.setting-body { flex: 1; }
|
|
||||||
.setting-label { font-weight: 600; font-size: var(--font-size-base); }
|
|
||||||
.setting-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; line-height: 1.5; }
|
|
||||||
.setting-control { flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* ── Settings section ── */
|
|
||||||
.settings-section {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.settings-section-header {
|
|
||||||
padding: 14px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: #FAFBFC;
|
|
||||||
}
|
|
||||||
.settings-section-title {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.settings-section-sub {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.settings-section-body { padding: 6px 20px; }
|
|
||||||
|
|
||||||
/* ── Connection info card ── */
|
|
||||||
.conn-info-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
.conn-info-row:last-child { border-bottom: none; }
|
|
||||||
.conn-info-label { width: 160px; color: var(--text-muted); font-size: var(--font-size-sm); flex-shrink: 0; }
|
|
||||||
.conn-info-value { flex: 1; font-family: var(--font-mono); font-size: var(--font-size-sm); }
|
|
||||||
.conn-info-status { flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* ── Number input ── */
|
|
||||||
input[type="number"].form-input {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
input[type="number"].form-input::-webkit-outer-spin-button,
|
|
||||||
input[type="number"].form-input::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
/* Modal and dialog styles */
|
|
||||||
|
|
||||||
/* ── Overlay ── */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(19, 0, 50, 0.5);
|
|
||||||
z-index: 200;
|
|
||||||
animation: backdropIn 0.15s ease;
|
|
||||||
}
|
|
||||||
@keyframes backdropIn { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
|
|
||||||
/* ── Modal box ── */
|
|
||||||
.modal-box {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
width: min(520px, 94vw);
|
|
||||||
max-height: 90vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
z-index: 201;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
animation: modalIn 0.18s ease;
|
|
||||||
}
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: translate(-50%, -52%); }
|
|
||||||
to { opacity: 1; transform: translate(-50%, -50%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-box.modal-lg { width: min(720px, 94vw); }
|
|
||||||
.modal-box.modal-sm { width: min(380px, 94vw); }
|
|
||||||
|
|
||||||
/* ── Modal sections ── */
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.modal-title {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.modal-body {
|
|
||||||
padding: 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.modal-footer {
|
|
||||||
padding: 14px 20px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--ecru);
|
|
||||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Close button ── */
|
|
||||||
.modal-close {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
line-height: 1;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.modal-close:hover { background: var(--ecru); color: var(--text); }
|
|
||||||
|
|
||||||
/* ── Options panel inside modal ── */
|
|
||||||
.options-panel {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 18px;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
.options-title {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.option-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.option-row:last-child { border-bottom: none; }
|
|
||||||
.option-label { font-weight: 600; font-size: var(--font-size-base); }
|
|
||||||
.option-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; }
|
|
||||||
.option-body { flex: 1; }
|
|
||||||
|
|
||||||
/* ── Progress inside modal ── */
|
|
||||||
.migration-progress {
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
.progress-template-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.progress-template-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--ecru);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
.progress-template-name { flex: 1; font-weight: 500; }
|
|
||||||
.progress-template-status { font-size: 16px; flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* ── Project switcher modal ── */
|
|
||||||
.project-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.project-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.project-row:hover { background: var(--ecru); }
|
|
||||||
.project-row.active {
|
|
||||||
border-color: var(--cobalt);
|
|
||||||
background: var(--cobalt-light);
|
|
||||||
}
|
|
||||||
.project-row-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--cobalt);
|
|
||||||
color: #fff;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 800;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.project-row-name { font-weight: 600; font-size: var(--font-size-base); flex: 1; }
|
|
||||||
.project-row-sub { font-size: var(--font-size-xs); color: var(--text-muted); }
|
|
||||||
.project-row-active-badge { font-size: var(--font-size-xs); color: var(--cobalt); font-weight: 700; }
|
|
||||||
|
|
||||||
/* ── New project form inside modal ── */
|
|
||||||
.new-project-form {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: 16px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.new-project-form h4 {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
/* Left sidebar navigation and top bar */
|
|
||||||
|
|
||||||
/* ── App layout shell ── */
|
|
||||||
#app-nav {
|
|
||||||
width: var(--nav-width);
|
|
||||||
background: var(--nav-bg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-left: var(--nav-width);
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Logo area ── */
|
|
||||||
#nav-logo {
|
|
||||||
padding: 14px 20px 12px;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
||||||
}
|
|
||||||
#nav-logo svg { display: block; }
|
|
||||||
.nav-logo-sub {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--nav-text);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Project switcher ── */
|
|
||||||
#nav-project-switcher {
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
#nav-project-switcher:hover { background: rgba(255,255,255,0.10); }
|
|
||||||
|
|
||||||
.project-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--cobalt);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.project-name {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
flex: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.project-arrow {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--nav-text);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.project-name.no-project { color: var(--warning-amber); }
|
|
||||||
|
|
||||||
/* ── Nav sections ── */
|
|
||||||
.nav-section-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(189,201,217,0.5);
|
|
||||||
padding: 16px 20px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav-links { list-style: none; flex: 1; overflow-y: auto; }
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 9px 20px;
|
|
||||||
color: var(--nav-text);
|
|
||||||
cursor: pointer;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
transition: all 0.15s;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
user-select: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.nav-item:hover {
|
|
||||||
background: var(--nav-hover);
|
|
||||||
color: var(--nav-text-active);
|
|
||||||
}
|
|
||||||
.nav-item.active {
|
|
||||||
background: var(--nav-active-bg);
|
|
||||||
color: var(--nav-text-active);
|
|
||||||
border-left-color: var(--nav-active-border);
|
|
||||||
}
|
|
||||||
.nav-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.nav-label { flex: 1; }
|
|
||||||
.nav-badge {
|
|
||||||
margin-left: auto;
|
|
||||||
background: var(--poppy);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1px 6px;
|
|
||||||
min-width: 18px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.nav-badge.amber { background: var(--warning-amber); }
|
|
||||||
.nav-badge[data-count="0"] { display: none; }
|
|
||||||
|
|
||||||
/* ── Nav bottom (customer context) ── */
|
|
||||||
#nav-bottom {
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.08);
|
|
||||||
}
|
|
||||||
.nav-customer { padding: 10px 20px; }
|
|
||||||
.nav-customer-label {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: rgba(189,201,217,0.5);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.nav-customer-name { font-size: var(--font-size-sm); font-weight: 600; color: var(--nav-text-active); }
|
|
||||||
.nav-customer-sub { font-size: var(--font-size-xs); color: var(--nav-text); }
|
|
||||||
|
|
||||||
/* ── Top bar ── */
|
|
||||||
#top-bar {
|
|
||||||
height: var(--topbar-h);
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 var(--space-lg);
|
|
||||||
gap: var(--space-md);
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.breadcrumb .sep { color: var(--border); }
|
|
||||||
.breadcrumb .current { color: var(--text); font-weight: 600; }
|
|
||||||
|
|
||||||
#topbar-right {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conn-pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
background: var(--card-bg);
|
|
||||||
}
|
|
||||||
.conn-pill:hover { background: var(--ecru); }
|
|
||||||
.conn-dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.conn-pill.connected .conn-dot { background: var(--success); }
|
|
||||||
.conn-pill.disconnected .conn-dot { background: var(--error); }
|
|
||||||
.conn-pill.connecting .conn-dot { background: var(--warning-amber); }
|
|
||||||
|
|
||||||
/* ── Router outlet ── */
|
|
||||||
#router-outlet {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── View transitions ── */
|
|
||||||
.view-enter {
|
|
||||||
animation: fadeIn 0.18s ease;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile nav toggle ── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#app-nav {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
#app-nav.open { transform: translateX(0); }
|
|
||||||
#app-body { margin-left: 0; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/* History and audit table styles */
|
|
||||||
|
|
||||||
/* ── Sortable column headers ── */
|
|
||||||
th.sortable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
th.sortable:hover { background: #F0F1F5; }
|
|
||||||
th.sortable::after {
|
|
||||||
content: ' ⇅';
|
|
||||||
font-size: 10px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
th.sort-asc::after { content: ' ↑'; opacity: 1; color: var(--cobalt); }
|
|
||||||
th.sort-desc::after { content: ' ↓'; opacity: 1; color: var(--cobalt); }
|
|
||||||
|
|
||||||
/* ── Expandable row ── */
|
|
||||||
.row-expandable { cursor: pointer; }
|
|
||||||
.row-expanded-content {
|
|
||||||
background: #FAFBFC;
|
|
||||||
}
|
|
||||||
.row-expand-body {
|
|
||||||
padding: 12px 14px 14px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.expand-icon { font-size: 10px; color: var(--text-muted); transition: transform 0.15s; }
|
|
||||||
tr.open .expand-icon { transform: rotate(90deg); }
|
|
||||||
|
|
||||||
/* ── Checksum display ── */
|
|
||||||
.checksum {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
background: var(--ecru);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Date range filter ── */
|
|
||||||
.date-filter {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
.date-input {
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--card-bg);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.date-input:focus { border-color: var(--cobalt); }
|
|
||||||
|
|
||||||
/* ── Pagination ── */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.pagination-pages { display: flex; gap: 4px; }
|
|
||||||
.page-btn {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--card-bg);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.page-btn:hover { background: var(--ecru); }
|
|
||||||
.page-btn.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); }
|
|
||||||
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
/* Docusign 2024 brand design tokens
|
|
||||||
Source: brand.docusign.com (April 2024 rebrand)
|
|
||||||
Inkwell #130032 replaces old navy; Cobalt #4C00FF is new primary.
|
|
||||||
*/
|
|
||||||
:root {
|
|
||||||
/* ── Brand palette ── */
|
|
||||||
--cobalt: #4C00FF;
|
|
||||||
--cobalt-hover: #3A00CC;
|
|
||||||
--cobalt-light: #EDE8FF;
|
|
||||||
--inkwell: #130032;
|
|
||||||
--deep-violet: #26065D;
|
|
||||||
--mist: #CBC2FF;
|
|
||||||
--ecru: #F8F3F0;
|
|
||||||
--poppy: #FF5252;
|
|
||||||
--poppy-bg: #FFF0F0;
|
|
||||||
--slate: #6B5F8A;
|
|
||||||
|
|
||||||
/* ── Semantic colours ── */
|
|
||||||
--success: #027A48;
|
|
||||||
--success-bg: #ECFDF3;
|
|
||||||
--warning: #92400E;
|
|
||||||
--warning-bg: #FFFBEB;
|
|
||||||
--warning-amber:#FFAB00;
|
|
||||||
--error: var(--poppy);
|
|
||||||
--error-bg: var(--poppy-bg);
|
|
||||||
|
|
||||||
/* ── Nav ── */
|
|
||||||
--nav-bg: var(--inkwell);
|
|
||||||
--nav-hover: var(--deep-violet);
|
|
||||||
--nav-active-bg: rgba(76,0,255,0.22);
|
|
||||||
--nav-active-border:var(--cobalt);
|
|
||||||
--nav-text: #A899CC;
|
|
||||||
--nav-text-active: #FFFFFF;
|
|
||||||
--nav-width: 228px;
|
|
||||||
|
|
||||||
/* ── Layout ── */
|
|
||||||
--topbar-h: 56px;
|
|
||||||
--page-bg: var(--ecru);
|
|
||||||
--card-bg: #FFFFFF;
|
|
||||||
--border: #E2DDF0;
|
|
||||||
--text: var(--inkwell);
|
|
||||||
--text-muted: var(--slate);
|
|
||||||
|
|
||||||
/* ── Shadows ── */
|
|
||||||
--shadow-sm: 0 1px 3px rgba(19,0,50,0.08), 0 1px 2px rgba(19,0,50,0.04);
|
|
||||||
--shadow-md: 0 4px 16px rgba(19,0,50,0.14);
|
|
||||||
|
|
||||||
/* ── Spacing ── */
|
|
||||||
--space-xs: 4px;
|
|
||||||
--space-sm: 8px;
|
|
||||||
--space-md: 16px;
|
|
||||||
--space-lg: 24px;
|
|
||||||
--space-xl: 32px;
|
|
||||||
|
|
||||||
/* ── Border radius ── */
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--radius-pill: 20px;
|
|
||||||
|
|
||||||
/* ── Typography ── */
|
|
||||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
||||||
--font-size-xs: 11px;
|
|
||||||
--font-size-sm: 12px;
|
|
||||||
--font-size-base:13px;
|
|
||||||
--font-size-md: 14px;
|
|
||||||
--font-size-lg: 16px;
|
|
||||||
--font-size-xl: 20px;
|
|
||||||
}
|
|
||||||
|
|
@ -3,164 +3,77 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>docusign — Template Migration Console</title>
|
<title>Adobe Sign → DocuSign Migrator</title>
|
||||||
<link rel="stylesheet" href="/static/css/tokens.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
<link rel="stylesheet" href="/static/css/base.css" />
|
|
||||||
<link rel="stylesheet" href="/static/css/nav.css" />
|
|
||||||
<link rel="stylesheet" href="/static/css/cards.css" />
|
|
||||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
|
||||||
<link rel="stylesheet" href="/static/css/tables.css" />
|
|
||||||
<link rel="stylesheet" href="/static/css/forms.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════
|
<header>
|
||||||
LEFT NAVIGATION
|
<h1>Adobe Sign → DocuSign Migrator</h1>
|
||||||
═══════════════════════════════════════════════════════════════ -->
|
<div id="auth-bar">
|
||||||
<nav id="app-nav">
|
<span id="badge-adobe" class="auth-badge">Connect Adobe Sign</span>
|
||||||
|
<span id="badge-docusign" class="auth-badge">Connect DocuSign</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Logo + project switcher -->
|
<main>
|
||||||
<div id="nav-logo">
|
|
||||||
<svg viewBox="0 0 1200 241.4" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
style="width:148px;height:auto;display:block;">
|
|
||||||
<style>.st0{fill:#4C00FF;}.st1{fill:#FF5252;}</style>
|
|
||||||
<g fill="#FFFFFF">
|
|
||||||
<g>
|
|
||||||
<path d="M1169.2,109.7v78.7h-28.9v-73.5c0-17.9-7.7-27.9-22.7-27.9s-24.9,10.5-27.7,28.1c-0.8,4.2-1,10.7-1,24.4v48.8H1060v-125h25.6c0.1,1.1,0.7,12.3,0.7,13c0,0.9,1.1,1.4,1.8,0.8c10.6-8.4,22.3-16.2,38.6-16.2C1153.5,60.9,1169.2,79,1169.2,109.7z"/>
|
|
||||||
<path d="M1013.4,63.4l-0.9,14.3c-0.1,0.9-1.2,1.4-1.8,0.8c-3.5-3.3-16.4-17.5-38.3-17.5c-31.4,0-54.5,27.1-54.5,63.9l0,0c0,37.3,22.9,64.5,54.5,64.5c21.1,0,34-13.7,36.4-16.7c0.7-0.8,2-0.3,2,0.7c-0.3,3.8-0.8,13.3-4,21.4c-4,10.2-13,19.7-31.1,19.7c-14.9,0-28.1-5.7-40.6-17.9L920,217.3c13.7,15.5,35.3,24.2,58.8,24.2c37.8,0,60.5-25.9,60.5-68.2V63.4H1013.4z M978.6,163.2c-18.7,0-31.9-16.2-31.9-38.3S959.9,87,978.6,87c18.7,0,31.9,15.7,31.9,37.9C1010.4,147.1,997.2,163.2,978.6,163.2z"/>
|
|
||||||
<path d="M857.5,151.3c0,23.7-19.9,39.6-49.1,39.6c-22.9,0-43.3-8.9-55.5-21.6l0,0l0,0l9.5-22.6c9.2,8.3,24,20.2,45.1,20.2c14.7,0,23.2-6.5,23.2-14.7c0-9.5-11.7-12-25.7-14.7c-19.9-4.2-46.3-11-46.3-38.1c0-22.7,18.4-38.3,45.6-38.3c20.9,0,38.9,8,51.3,18.4l-14.2,19.9c-12-9.5-24.6-14.2-37.1-14.2s-18.7,5.2-18.7,12.7c0,10.5,13.5,13.2,23.4,15.2C833.9,117.9,857.5,125.4,857.5,151.3z"/>
|
|
||||||
<path d="M434.9,60.9c-35.3,0-60.7,27.4-60.7,65s25.4,65,60.7,65s60.8-27.4,60.8-65S470.3,60.9,434.9,60.9z M434.9,164.7c-18.7,0-31.9-15.9-31.9-38.9c0-22.9,12.9-38.9,31.9-38.9c18.9,0,31.9,15.9,31.9,38.9S453.6,164.7,434.9,164.7z"/>
|
|
||||||
<path d="M505.9,125.9c0-37.1,25.4-65,59.3-65c26.9,0,46.6,13.5,55.8,38.9l-25.6,9.7c-7-15.7-16.2-22.4-30.1-22.4c-17.4,0-30.4,16.4-30.4,38.9c0,22.4,12.9,38.9,30.4,38.9c14,0,23.1-6.7,30.1-22.4l25.6,9.7c-9.2,25.4-28.9,38.9-55.8,38.9C531.3,190.9,505.9,163,505.9,125.9z"/>
|
|
||||||
<path d="M351.4,5.3c-0.5,0-1.1,0.1-1.6,0.4l-18.8,10c-0.4,0.2-0.6,0.6-0.6,1v59.5c0,1-1.2,1.4-1.9,0.8c-2.8-2.4-9.3-8.5-18.3-12.7c-4.7-2.2-11.6-3.4-17.9-3.4c-31.6,0-54.5,27.4-54.5,65s22.9,65,54.5,65c16.6,0,29.1-8.7,36.7-16.5c0.5-0.5,0.8-0.8,1.3-1.3c0.7-0.7,1.9-0.3,1.9,0.7l1,14.6h26.1V6.1c0-0.4-0.3-0.8-0.8-0.8C358.5,5.3,351.4,5.3,351.4,5.3z M298.5,164.7c-18.9,0-31.9-15.9-31.9-38.9S279.9,87,298.5,87c18.7,0,31.9,15.9,31.9,38.9C330.4,148.8,317.5,164.7,298.5,164.7z"/>
|
|
||||||
<path d="M891.5,63.8l-18.1,9.6c-0.4,0.2-0.6,0.6-0.6,1v114h28.9V64.1c0-0.4-0.3-0.8-0.8-0.8h-7.8C892.5,63.4,892,63.5,891.5,63.8z"/>
|
|
||||||
<path d="M887.2,43.1c9.6,0,17.4-7.8,17.4-17.4s-7.8-17.4-17.4-17.4c-9.6,0-17.4,7.8-17.4,17.4S877.6,43.1,887.2,43.1z"/>
|
|
||||||
<path d="M742.5,63.3v67.9c0,51.5-28.8,59.6-54.5,59.6s-54.5-8.2-54.5-59.6V63.3h28.8v75.1c0,7.3,1.8,26.3,25.7,26.3s25.7-18.9,25.7-26.3V63.3H742.5z"/>
|
|
||||||
</g>
|
|
||||||
<g fill="#FFFFFF">
|
|
||||||
<path d="M1185.7,175.6v1.8h-4.1v10.9h-2v-10.9h-4.1v-1.8H1185.7z M1200,188.3h-2v-10l-3.9,7.5h-1.1l-3.9-7.4v9.9h-2v-12.7h2.6l3.8,7.3l3.8-7.3h2.6L1200,188.3z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<path class="st0" d="M139.5,139.5V189c0,2.6-2.1,4.7-4.7,4.7H4.7c-2.6,0-4.7-2.1-4.7-4.7V59c0-2.6,2.1-4.7,4.7-4.7h49.4v80.5c0,2.6,2.1,4.7,4.7,4.7H139.5z"/>
|
|
||||||
<path class="st1" d="M193.7,69.7c0,41.6-24.3,69.7-54.2,69.8V87.1c0-1.5-0.6-3-1.7-4l-27.2-27.2c-1.1-1.1-2.5-1.7-4-1.7H54.2V4.8c0-2.6,2.1-4.7,4.7-4.7h73.3C167,0,193.7,28,193.7,69.7z"/>
|
|
||||||
<path fill="#FFFFFF" d="M137.8,83c1.1,1.1,1.7,2.5,1.7,4v52.4H58.9c-2.6,0-4.7-2.1-4.7-4.7V54.2h52.4c1.5,0,3,0.6,4,1.7L137.8,83z"/>
|
|
||||||
</svg>
|
|
||||||
<div class="nav-logo-sub">Template Migration Console</div>
|
|
||||||
|
|
||||||
<!-- Project switcher button -->
|
<!-- Action bar -->
|
||||||
<button id="nav-project-switcher" aria-label="Switch project">
|
<div class="action-bar">
|
||||||
<div class="project-icon" id="nav-project-icon">?</div>
|
<button id="btn-migrate" disabled>Migrate Selected</button>
|
||||||
<div class="project-name no-project" id="nav-project-name">New Project</div>
|
<button id="btn-refresh">↻ Refresh</button>
|
||||||
<div class="project-arrow">⇅</div>
|
<span id="status-msg">Loading…</span>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nav links -->
|
<!-- Side-by-side panels -->
|
||||||
<ul id="nav-links">
|
<div class="panel-row">
|
||||||
<li class="nav-section-label">Migration</li>
|
|
||||||
<li>
|
|
||||||
<a class="nav-item" data-route="#/templates" href="#/templates">
|
|
||||||
<span class="nav-icon">☰</span>
|
|
||||||
<span class="nav-label">Templates</span>
|
|
||||||
<span class="nav-badge amber" id="nav-badge-caveats" data-count="0">0</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="nav-item" data-route="#/results" href="#/results">
|
|
||||||
<span class="nav-icon">⬡</span>
|
|
||||||
<span class="nav-label">Migration Results</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="nav-item" data-route="#/issues" href="#/issues">
|
|
||||||
<span class="nav-icon">⚠</span>
|
|
||||||
<span class="nav-label">Issues & Warnings</span>
|
|
||||||
<span class="nav-badge" id="nav-badge-issues" data-count="0">0</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="nav-section-label">Post-Migration</li>
|
<div class="panel">
|
||||||
<li>
|
<div class="panel-header">
|
||||||
<a class="nav-item" data-route="#/verify" href="#/verify">
|
<span>Adobe Sign Templates</span>
|
||||||
<span class="nav-icon">✓</span>
|
<span style="font-weight:400;font-size:12px;color:#888">Select to migrate →</span>
|
||||||
<span class="nav-label">Verification</span>
|
</div>
|
||||||
</a>
|
<div class="panel-body">
|
||||||
</li>
|
<ul class="template-list" id="adobe-list">
|
||||||
<li>
|
<li class="empty-msg">Loading…</li>
|
||||||
<a class="nav-item" data-route="#/history" href="#/history">
|
</ul>
|
||||||
<span class="nav-icon">◷</span>
|
</div>
|
||||||
<span class="nav-label">History & Audit</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="nav-section-label">Admin</li>
|
|
||||||
<li>
|
|
||||||
<a class="nav-item" data-route="#/settings" href="#/settings">
|
|
||||||
<span class="nav-icon">⚙</span>
|
|
||||||
<span class="nav-label">Settings</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Bottom: customer context -->
|
|
||||||
<div id="nav-bottom">
|
|
||||||
<div class="nav-customer">
|
|
||||||
<div class="nav-customer-label">Current Project</div>
|
|
||||||
<div class="nav-customer-name" id="nav-customer-name">—</div>
|
|
||||||
<div class="nav-customer-sub" id="nav-customer-sub"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>DocuSign Templates</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<ul class="template-list" id="ds-list">
|
||||||
|
<li class="empty-msg">Loading…</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</nav>
|
<!-- Migration history -->
|
||||||
|
<div class="history-section">
|
||||||
|
<div class="panel-header">Migration History</div>
|
||||||
|
<table class="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Adobe Template</th>
|
||||||
|
<th>DocuSign Template ID</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-tbody">
|
||||||
|
<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════
|
</main>
|
||||||
MAIN CONTENT AREA
|
|
||||||
═══════════════════════════════════════════════════════════════ -->
|
|
||||||
<div id="app-body">
|
|
||||||
|
|
||||||
<!-- Top bar -->
|
|
||||||
<header id="top-bar">
|
|
||||||
<nav class="breadcrumb" aria-label="breadcrumb">
|
|
||||||
<span>Migration Console</span>
|
|
||||||
<span class="sep">›</span>
|
|
||||||
<span class="current" id="breadcrumb-current">Templates</span>
|
|
||||||
</nav>
|
|
||||||
<div id="topbar-right">
|
|
||||||
<!-- Auth connection chips -->
|
|
||||||
<button id="chip-adobe" class="conn-pill disconnected" aria-label="Adobe Sign connection">
|
|
||||||
<span class="conn-dot"></span>Adobe Sign
|
|
||||||
</button>
|
|
||||||
<button id="chip-docusign" class="conn-pill disconnected" aria-label="DocuSign connection">
|
|
||||||
<span class="conn-dot"></span>DocuSign
|
|
||||||
</button>
|
|
||||||
<!-- User avatar -->
|
|
||||||
<div class="avatar" title="Logged in" aria-label="User">M</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Router outlet — views injected here -->
|
|
||||||
<main id="router-outlet">
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">⏳</div>
|
|
||||||
<div class="empty-state-title">Loading…</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════
|
|
||||||
MODAL OVERLAY (shared, managed by modals.js)
|
|
||||||
═══════════════════════════════════════════════════════════════ -->
|
|
||||||
<div id="modal-root"></div>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════
|
|
||||||
TOAST CONTAINER (managed by auth.js)
|
|
||||||
═══════════════════════════════════════════════════════════════ -->
|
|
||||||
<div id="toast-container"></div>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════
|
|
||||||
APP ENTRY POINT
|
|
||||||
═══════════════════════════════════════════════════════════════ -->
|
|
||||||
<script type="module" src="/static/js/app.js"></script>
|
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
// Thin fetch wrappers for all backend endpoints
|
|
||||||
|
|
||||||
async function request(method, path, body) {
|
|
||||||
const opts = {
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
||||||
const resp = await fetch(path, opts);
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) {
|
|
||||||
const msg = data.detail || data.error || `HTTP ${resp.status}`;
|
|
||||||
throw Object.assign(new Error(msg), { status: resp.status, data });
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GET = path => request('GET', path);
|
|
||||||
const POST = (path, body) => request('POST', path, body);
|
|
||||||
const PUT = (path, body) => request('PUT', path, body);
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
||||||
auth: {
|
|
||||||
status() {
|
|
||||||
return GET('/api/auth/status');
|
|
||||||
},
|
|
||||||
connectAdobe() {
|
|
||||||
return POST('/api/auth/adobe/connect');
|
|
||||||
},
|
|
||||||
adobeUrl() {
|
|
||||||
return GET('/api/auth/adobe/url');
|
|
||||||
},
|
|
||||||
exchangeAdobe(redirectUrl) {
|
|
||||||
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
|
|
||||||
},
|
|
||||||
connectDocusign() {
|
|
||||||
return POST('/api/auth/docusign/connect');
|
|
||||||
},
|
|
||||||
disconnect(platform) {
|
|
||||||
return POST(`/api/auth/${platform}/disconnect`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Templates ─────────────────────────────────────────────────────────────
|
|
||||||
templates: {
|
|
||||||
status() {
|
|
||||||
return GET('/api/templates/status');
|
|
||||||
},
|
|
||||||
adobe() {
|
|
||||||
return GET('/api/templates/adobe');
|
|
||||||
},
|
|
||||||
docusign() {
|
|
||||||
return GET('/api/templates/docusign');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Migration ─────────────────────────────────────────────────────────────
|
|
||||||
migrate: {
|
|
||||||
run(body) {
|
|
||||||
return POST('/api/migrate', body);
|
|
||||||
},
|
|
||||||
batch(body) {
|
|
||||||
return POST('/api/migrate/batch', body);
|
|
||||||
},
|
|
||||||
batchStatus(jobId) {
|
|
||||||
return GET(`/api/migrate/batch/${jobId}`);
|
|
||||||
},
|
|
||||||
history() {
|
|
||||||
return GET('/api/migrate/history');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Verification ──────────────────────────────────────────────────────────
|
|
||||||
verify: {
|
|
||||||
send(templateId, recipientName, recipientEmail) {
|
|
||||||
return POST('/api/verify/send', {
|
|
||||||
template_id: templateId,
|
|
||||||
recipient_name: recipientName,
|
|
||||||
recipient_email: recipientEmail,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
status(envelopeId) {
|
|
||||||
return GET(`/api/verify/status/${envelopeId}`);
|
|
||||||
},
|
|
||||||
void(envelopeId, reason = 'Test envelope — voided after verification') {
|
|
||||||
return POST(`/api/verify/void/${envelopeId}`, { reason });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
// Main app entry point — wires together router, auth, state, and nav badges
|
|
||||||
|
|
||||||
import * as router from './router.js';
|
|
||||||
import { refreshAuth, renderAuthChips } from './auth.js';
|
|
||||||
import { state, subscribe } from './state.js';
|
|
||||||
import { getActive, initProject } from './project.js';
|
|
||||||
|
|
||||||
// ── Route registrations (lazy-loaded views) ───────────────────────────────
|
|
||||||
|
|
||||||
router.register('#/templates', async (param) => {
|
|
||||||
const { renderTemplates, renderTemplateDetail } = await import('./templates.js');
|
|
||||||
if (param) {
|
|
||||||
await renderTemplateDetail(param);
|
|
||||||
} else {
|
|
||||||
await renderTemplates();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.register('#/results', async () => {
|
|
||||||
const { renderResults } = await import('./migration.js');
|
|
||||||
renderResults();
|
|
||||||
});
|
|
||||||
|
|
||||||
router.register('#/issues', async () => {
|
|
||||||
const { renderIssues } = await import('./issues.js');
|
|
||||||
renderIssues();
|
|
||||||
});
|
|
||||||
|
|
||||||
router.register('#/verify', async () => {
|
|
||||||
const { renderVerification } = await import('./verification.js');
|
|
||||||
await renderVerification();
|
|
||||||
});
|
|
||||||
|
|
||||||
router.register('#/history', async () => {
|
|
||||||
const { renderHistory } = await import('./history.js');
|
|
||||||
await renderHistory();
|
|
||||||
});
|
|
||||||
|
|
||||||
router.register('#/settings', async () => {
|
|
||||||
const { renderSettings } = await import('./settings.js');
|
|
||||||
renderSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Nav badge subscriptions ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
subscribe('issueCount', count => {
|
|
||||||
const badge = document.getElementById('nav-badge-issues');
|
|
||||||
if (badge) {
|
|
||||||
badge.dataset.count = count;
|
|
||||||
badge.textContent = count;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribe('templates', templates => {
|
|
||||||
const caveats = (templates || []).filter(t =>
|
|
||||||
(!t.blockers || t.blockers.length === 0) &&
|
|
||||||
t.warnings && t.warnings.length > 0
|
|
||||||
).length;
|
|
||||||
const badge = document.getElementById('nav-badge-caveats');
|
|
||||||
if (badge) {
|
|
||||||
badge.dataset.count = caveats;
|
|
||||||
badge.textContent = caveats;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Project switcher wiring ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
function syncProjectDisplay() {
|
|
||||||
const project = getActive();
|
|
||||||
const iconEl = document.getElementById('nav-project-icon');
|
|
||||||
const nameEl = document.getElementById('nav-project-name');
|
|
||||||
const custName = document.getElementById('nav-customer-name');
|
|
||||||
const custSub = document.getElementById('nav-customer-sub');
|
|
||||||
|
|
||||||
if (project) {
|
|
||||||
const initials = project.name.slice(0, 2).toUpperCase();
|
|
||||||
if (iconEl) { iconEl.textContent = initials; }
|
|
||||||
if (nameEl) { nameEl.textContent = project.name; nameEl.classList.remove('no-project'); }
|
|
||||||
if (custName) { custName.textContent = project.name; }
|
|
||||||
if (custSub) { custSub.textContent = `Created ${new Date(project.createdAt).toLocaleDateString()}`; }
|
|
||||||
} else {
|
|
||||||
if (iconEl) { iconEl.textContent = '?'; }
|
|
||||||
if (nameEl) { nameEl.textContent = 'New Project'; nameEl.classList.add('no-project'); }
|
|
||||||
if (custName) { custName.textContent = '—'; }
|
|
||||||
if (custSub) { custSub.textContent = ''; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Init ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
// Init project context
|
|
||||||
initProject(syncProjectDisplay);
|
|
||||||
|
|
||||||
// Wire project switcher button
|
|
||||||
const switcher = document.getElementById('nav-project-switcher');
|
|
||||||
if (switcher) {
|
|
||||||
switcher.addEventListener('click', async () => {
|
|
||||||
const { showProjectModal } = await import('./project.js');
|
|
||||||
showProjectModal(syncProjectDisplay);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth chips
|
|
||||||
await refreshAuth();
|
|
||||||
|
|
||||||
// Start router
|
|
||||||
router.init();
|
|
||||||
});
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
// Auth: connect/disconnect Adobe Sign and DocuSign, auth status chips
|
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
import { state, setState } from './state.js';
|
|
||||||
import { escHtml } from './utils.js';
|
|
||||||
|
|
||||||
// ── Refresh auth state and update chips ────────────────────────────────────
|
|
||||||
|
|
||||||
export async function refreshAuth() {
|
|
||||||
try {
|
|
||||||
const data = await api.auth.status();
|
|
||||||
setState('auth', {
|
|
||||||
adobe: !!data.adobe,
|
|
||||||
docusign: !!data.docusign,
|
|
||||||
adobeLabel: data.adobe_label || 'Adobe Sign',
|
|
||||||
docusignLabel: data.docusign_label || 'DocuSign',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Auth status failed:', e.message);
|
|
||||||
}
|
|
||||||
renderAuthChips();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render connection pills in top bar ─────────────────────────────────────
|
|
||||||
|
|
||||||
export function renderAuthChips() {
|
|
||||||
renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe);
|
|
||||||
renderChip('chip-docusign', state.auth.docusign, 'DocuSign', onClickDocusign);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChip(id, connected, label, onClick) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.className = 'conn-pill ' + (connected ? 'connected' : 'disconnected');
|
|
||||||
el.innerHTML = `<span class="conn-dot"></span>${escHtml(label)}`;
|
|
||||||
el.onclick = onClick;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Click handlers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function onClickAdobe() {
|
|
||||||
if (state.auth.adobe) {
|
|
||||||
await disconnect('adobe');
|
|
||||||
} else {
|
|
||||||
await connectAdobeEnv();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onClickDocusign() {
|
|
||||||
if (state.auth.docusign) {
|
|
||||||
await disconnect('docusign');
|
|
||||||
} else {
|
|
||||||
await connectDocusign();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disconnect(platform) {
|
|
||||||
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
|
|
||||||
try {
|
|
||||||
await api.auth.disconnect(platform);
|
|
||||||
setState('auth', { ...state.auth, [platform]: false });
|
|
||||||
renderAuthChips();
|
|
||||||
// Reload templates (they'll be empty without auth)
|
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
|
||||||
refreshTemplates();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Disconnect failed:', e.message);
|
|
||||||
renderAuthChips();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectAdobeEnv() {
|
|
||||||
setChipConnecting('chip-adobe');
|
|
||||||
try {
|
|
||||||
const data = await api.auth.connectAdobe();
|
|
||||||
if (data.connected) {
|
|
||||||
setState('auth', { ...state.auth, adobe: true });
|
|
||||||
renderAuthChips();
|
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
|
||||||
refreshTemplates();
|
|
||||||
} else if (data.error && data.error.includes('No Adobe Sign credentials')) {
|
|
||||||
renderAuthChips();
|
|
||||||
showAdobeOAuthDialog();
|
|
||||||
} else {
|
|
||||||
renderAuthChips();
|
|
||||||
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
renderAuthChips();
|
|
||||||
showAdobeOAuthDialog();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectDocusign() {
|
|
||||||
setChipConnecting('chip-docusign');
|
|
||||||
try {
|
|
||||||
const data = await api.auth.connectDocusign();
|
|
||||||
if (data.connected) {
|
|
||||||
setState('auth', { ...state.auth, docusign: true });
|
|
||||||
renderAuthChips();
|
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
|
||||||
refreshTemplates();
|
|
||||||
} else {
|
|
||||||
renderAuthChips();
|
|
||||||
showToast('DocuSign error: ' + (data.error || 'unknown'), 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
renderAuthChips();
|
|
||||||
showToast('DocuSign connection failed: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setChipConnecting(id) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.className = 'conn-pill connecting';
|
|
||||||
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
|
|
||||||
|
|
||||||
async function showAdobeOAuthDialog() {
|
|
||||||
const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' }));
|
|
||||||
|
|
||||||
const existing = document.getElementById('adobe-auth-dialog');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
const dialog = document.createElement('div');
|
|
||||||
dialog.id = 'adobe-auth-dialog';
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="modal-backdrop"></div>
|
|
||||||
<div class="modal-box">
|
|
||||||
<div class="modal-header">
|
|
||||||
<span class="modal-title">Connect Adobe Sign</span>
|
|
||||||
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<ol style="padding-left:18px;line-height:1.8;margin-bottom:14px;font-size:13px">
|
|
||||||
<li><a href="${escHtml(url)}" target="_blank" rel="noopener" style="color:var(--cobalt)">Click here to authorize in Adobe Sign ↗</a></li>
|
|
||||||
<li>After authorizing, your browser will show a page that fails to load — that's expected.</li>
|
|
||||||
<li>Copy the full URL from the address bar and paste it below.</li>
|
|
||||||
</ol>
|
|
||||||
<input type="text" id="adobe-redirect-input" class="form-input"
|
|
||||||
placeholder="https://localhost:8080/callback?code=…" />
|
|
||||||
<div id="adobe-dialog-error" style="color:var(--error);font-size:12px;min-height:18px;margin-top:6px"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
|
|
||||||
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(dialog);
|
|
||||||
|
|
||||||
document.getElementById('adobe-dialog-close').onclick = () => dialog.remove();
|
|
||||||
document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove();
|
|
||||||
document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog);
|
|
||||||
document.getElementById('adobe-redirect-input').addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') submitAdobeCode(dialog);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitAdobeCode(dialog) {
|
|
||||||
const url = document.getElementById('adobe-redirect-input').value.trim();
|
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
const submitBtn = document.getElementById('adobe-dialog-submit');
|
|
||||||
const errorEl = document.getElementById('adobe-dialog-error');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Connecting…';
|
|
||||||
errorEl.textContent = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.auth.exchangeAdobe(url);
|
|
||||||
dialog.remove();
|
|
||||||
setState('auth', { ...state.auth, adobe: true });
|
|
||||||
renderAuthChips();
|
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
|
||||||
refreshTemplates();
|
|
||||||
} catch (e) {
|
|
||||||
errorEl.textContent = e.data?.error || e.message || 'Connection failed.';
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Connect';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toast notification ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function showToast(message, type = 'info') {
|
|
||||||
let container = document.getElementById('toast-container');
|
|
||||||
if (!container) {
|
|
||||||
container = document.createElement('div');
|
|
||||||
container.id = 'toast-container';
|
|
||||||
container.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;';
|
|
||||||
document.body.appendChild(container);
|
|
||||||
}
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
const colors = { info: 'var(--cobalt-light)', error: 'var(--error-bg)', success: 'var(--success-bg)' };
|
|
||||||
const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' };
|
|
||||||
toast.style.cssText = `
|
|
||||||
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
|
|
||||||
background:${colors[type]||colors.info};border:1px solid ${borders[type]||borders.info};
|
|
||||||
box-shadow:var(--shadow-md);max-width:360px;animation:fadeIn 0.2s ease;
|
|
||||||
`;
|
|
||||||
toast.textContent = message;
|
|
||||||
container.appendChild(toast);
|
|
||||||
setTimeout(() => toast.remove(), 4000);
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
// History & Audit view — filterable, exportable migration history
|
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
import { escHtml, formatDateTime, shortHash, downloadCsv, debounce } from './utils.js';
|
|
||||||
|
|
||||||
let _allRecords = [];
|
|
||||||
let _filter = { search: '', status: 'all', from: '', to: '' };
|
|
||||||
let _sort = { col: 'timestamp', dir: 'desc' };
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
let _page = 0;
|
|
||||||
|
|
||||||
export async function renderHistory() {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.migrate.history();
|
|
||||||
_allRecords = (data.history || []).reverse(); // newest first
|
|
||||||
} catch (e) {
|
|
||||||
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history: ${escHtml(e.message)}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_page = 0;
|
|
||||||
_render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _render() {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const filtered = _applyFilter(_allRecords);
|
|
||||||
const page = filtered.slice(_page * PAGE_SIZE, (_page + 1) * PAGE_SIZE);
|
|
||||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
|
||||||
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<div class="page-title">History & Audit</div>
|
|
||||||
<div class="page-subtitle">${_allRecords.length} total migration records</div>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-export-history">⬇ Export CSV</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter bar -->
|
|
||||||
<div class="filter-bar">
|
|
||||||
<input type="search" class="search-input" id="hist-search"
|
|
||||||
placeholder="Search by template name…" value="${escHtml(_filter.search)}" />
|
|
||||||
<div class="filter-tabs">
|
|
||||||
<button class="filter-tab ${_filter.status === 'all' ? 'active' : ''}" data-status="all">All</button>
|
|
||||||
<button class="filter-tab ${_filter.status === 'success' ? 'active' : ''}" data-status="success">Success</button>
|
|
||||||
<button class="filter-tab ${_filter.status === 'error' ? 'active' : ''}" data-status="error">Errors</button>
|
|
||||||
<button class="filter-tab ${_filter.status === 'dry_run' ? 'active' : ''}" data-status="dry_run">Dry Run</button>
|
|
||||||
<button class="filter-tab ${_filter.status === 'skipped' ? 'active' : ''}" data-status="skipped">Skipped</button>
|
|
||||||
</div>
|
|
||||||
<div class="date-filter">
|
|
||||||
<label style="font-size:11px;color:var(--text-muted)">From:</label>
|
|
||||||
<input type="date" class="date-input" id="hist-from" value="${_filter.from}" />
|
|
||||||
<label style="font-size:11px;color:var(--text-muted)">To:</label>
|
|
||||||
<input type="date" class="date-input" id="hist-to" value="${_filter.to}" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${filtered.length === 0 ? `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">📋</div>
|
|
||||||
<div class="empty-state-title">${_allRecords.length ? 'No records match your filter' : 'No migration history yet'}</div>
|
|
||||||
<div class="empty-state-sub">${_allRecords.length ? 'Try clearing the search or filters.' : 'Run a migration to see history here.'}</div>
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div class="card">
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
${_th('timestamp', 'Time')}
|
|
||||||
${_th('adobe_template_name', 'Template')}
|
|
||||||
${_th('action', 'Action')}
|
|
||||||
${_th('status', 'Status')}
|
|
||||||
<th>DocuSign ID</th>
|
|
||||||
<th>Checksum</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${page.map(r => _historyRow(r)).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${totalPages > 1 ? `
|
|
||||||
<div class="pagination">
|
|
||||||
<span>Showing ${_page * PAGE_SIZE + 1}–${Math.min((_page + 1) * PAGE_SIZE, filtered.length)} of ${filtered.length}</span>
|
|
||||||
<div class="pagination-pages">
|
|
||||||
<button class="page-btn" id="pg-prev" ${_page === 0 ? 'disabled' : ''}>‹ Prev</button>
|
|
||||||
<button class="page-btn" id="pg-next" ${_page >= totalPages - 1 ? 'disabled' : ''}>Next ›</button>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
_bindEvents(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _th(col, label) {
|
|
||||||
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
|
||||||
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _historyRow(r) {
|
|
||||||
const statusBadge = r.status === 'success'
|
|
||||||
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</span>`
|
|
||||||
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : r.status === 'dry_run' ? 'gray' : 'red'}">${escHtml(r.status || '—')}</span>`;
|
|
||||||
|
|
||||||
const checksum = r.checksum_sha256 || r.checksum || '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr class="row-expandable" data-expanded="false">
|
|
||||||
<td style="white-space:nowrap;font-size:12px">${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
|
|
||||||
<td>
|
|
||||||
<div class="table-name">${escHtml(r.adobe_template_name || r.adobe_template_id || '—')}</div>
|
|
||||||
<div class="table-sub">${escHtml(r.adobe_template_id || '')}</div>
|
|
||||||
</td>
|
|
||||||
<td>${escHtml(r.action || '—')}</td>
|
|
||||||
<td>${statusBadge}</td>
|
|
||||||
<td class="mono" style="font-size:11px">${r.docusign_template_id ? escHtml(r.docusign_template_id.slice(0,12)) + '…' : '—'}</td>
|
|
||||||
<td>
|
|
||||||
${checksum
|
|
||||||
? `<span class="checksum" title="${escHtml(checksum)}">${escHtml(shortHash(checksum))}</span>`
|
|
||||||
: '<span style="color:var(--text-muted)">—</span>'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
${(r.blockers || r.warnings || r.error) ? `
|
|
||||||
<tr class="row-expanded-content" style="display:none">
|
|
||||||
<td colspan="6">
|
|
||||||
<div class="row-expand-body">
|
|
||||||
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
|
|
||||||
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
|
|
||||||
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>` : ''}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _applyFilter(records) {
|
|
||||||
let list = [...records];
|
|
||||||
|
|
||||||
if (_filter.search) {
|
|
||||||
const q = _filter.search.toLowerCase();
|
|
||||||
list = list.filter(r =>
|
|
||||||
(r.adobe_template_name || '').toLowerCase().includes(q) ||
|
|
||||||
(r.adobe_template_id || '').toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_filter.status !== 'all') {
|
|
||||||
list = list.filter(r => r.status === _filter.status);
|
|
||||||
}
|
|
||||||
if (_filter.from) {
|
|
||||||
list = list.filter(r => r.timestamp >= _filter.from);
|
|
||||||
}
|
|
||||||
if (_filter.to) {
|
|
||||||
list = list.filter(r => r.timestamp <= _filter.to + 'T23:59:59');
|
|
||||||
}
|
|
||||||
|
|
||||||
list.sort((a, b) => {
|
|
||||||
const va = a[_sort.col] || '';
|
|
||||||
const vb = b[_sort.col] || '';
|
|
||||||
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
|
||||||
});
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _bindEvents(filtered) {
|
|
||||||
document.getElementById('hist-search')?.addEventListener('input', debounce(e => {
|
|
||||||
_filter.search = e.target.value; _page = 0; _render();
|
|
||||||
}, 250));
|
|
||||||
|
|
||||||
document.querySelectorAll('.filter-tab[data-status]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => { _filter.status = btn.dataset.status; _page = 0; _render(); });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('hist-from')?.addEventListener('change', e => { _filter.from = e.target.value; _page = 0; _render(); });
|
|
||||||
document.getElementById('hist-to')?.addEventListener('change', e => { _filter.to = e.target.value; _page = 0; _render(); });
|
|
||||||
|
|
||||||
document.querySelectorAll('th.sortable').forEach(th => {
|
|
||||||
th.addEventListener('click', () => {
|
|
||||||
const col = th.dataset.col;
|
|
||||||
_sort.dir = _sort.col === col && _sort.dir === 'asc' ? 'desc' : 'asc';
|
|
||||||
_sort.col = col; _page = 0; _render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pg-prev')?.addEventListener('click', () => { if (_page > 0) { _page--; _render(); } });
|
|
||||||
document.getElementById('pg-next')?.addEventListener('click', () => { _page++; _render(); });
|
|
||||||
|
|
||||||
// Expand rows
|
|
||||||
document.querySelectorAll('.row-expandable').forEach(row => {
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
const next = row.nextElementSibling;
|
|
||||||
if (next && next.classList.contains('row-expanded-content')) {
|
|
||||||
const open = next.style.display !== 'none';
|
|
||||||
next.style.display = open ? 'none' : 'table-row';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-export-history')?.addEventListener('click', () => {
|
|
||||||
downloadCsv('migration-history.csv', filtered.map(r => ({
|
|
||||||
timestamp: r.timestamp || '',
|
|
||||||
template: r.adobe_template_name || r.adobe_template_id || '',
|
|
||||||
adobe_id: r.adobe_template_id || '',
|
|
||||||
docusign_id: r.docusign_template_id || '',
|
|
||||||
action: r.action || '',
|
|
||||||
status: r.status || '',
|
|
||||||
checksum: r.checksum_sha256 || '',
|
|
||||||
warnings: (r.warnings || []).join('; '),
|
|
||||||
})));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
// Issues & Warnings view — surfaces all validation problems before migration
|
|
||||||
|
|
||||||
import { state } from './state.js';
|
|
||||||
import { escHtml, formatDate } from './utils.js';
|
|
||||||
import { navigate } from './router.js';
|
|
||||||
|
|
||||||
export function renderIssues() {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const templates = state.templates || [];
|
|
||||||
|
|
||||||
const blocked = templates.filter(t => t.blockers && t.blockers.length > 0);
|
|
||||||
const warnings = templates.filter(t =>
|
|
||||||
(!t.blockers || t.blockers.length === 0) && t.warnings && t.warnings.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!state.auth.adobe || !state.auth.docusign) {
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header"><div><div class="page-title">Issues & Warnings</div></div></div>
|
|
||||||
<div class="callout info"><span class="callout-icon">ℹ️</span>Connect both platforms to see validation results.</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!blocked.length && !warnings.length) {
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<div class="page-title">Issues & Warnings</div>
|
|
||||||
<div class="page-subtitle">${templates.length} templates analyzed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="callout success" style="font-size:14px">
|
|
||||||
<span class="callout-icon">🎉</span>
|
|
||||||
<div>
|
|
||||||
<strong>All templates are ready!</strong>
|
|
||||||
<div style="margin-top:4px">No blockers or warnings found across ${templates.length} template${templates.length !== 1 ? 's' : ''}.</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<div class="page-title">Issues & Warnings</div>
|
|
||||||
<div class="page-subtitle">${templates.length} templates analyzed —
|
|
||||||
${blocked.length ? `<span style="color:var(--error);font-weight:600">${blocked.length} blocked</span>` : ''}
|
|
||||||
${blocked.length && warnings.length ? ', ' : ''}
|
|
||||||
${warnings.length ? `<span style="color:var(--warning);font-weight:600">${warnings.length} with warnings</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<a href="#/templates" class="btn btn-secondary btn-sm">← All Templates</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${blocked.length ? `
|
|
||||||
<div style="margin-bottom:24px">
|
|
||||||
<div style="font-size:14px;font-weight:700;color:var(--error);margin-bottom:10px">
|
|
||||||
🚫 Blockers — ${blocked.length} template${blocked.length > 1 ? 's' : ''} will fail migration
|
|
||||||
</div>
|
|
||||||
<div class="attention-list">
|
|
||||||
${blocked.map(t => _blockerItem(t)).join('')}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
${warnings.length ? `
|
|
||||||
<div>
|
|
||||||
<div style="font-size:14px;font-weight:700;color:var(--warning);margin-bottom:10px">
|
|
||||||
⚠ Warnings — ${warnings.length} template${warnings.length > 1 ? 's' : ''} will migrate with caveats
|
|
||||||
</div>
|
|
||||||
<div class="attention-list">
|
|
||||||
${warnings.map(t => _warningItem(t)).join('')}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Migrate Anyway buttons
|
|
||||||
document.querySelectorAll('.btn-migrate-anyway').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
import('./migration.js').then(m => m.showOptionsModal([btn.dataset.id]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// View Template links
|
|
||||||
document.querySelectorAll('.btn-view-template').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => navigate(`#/templates/${btn.dataset.id}`));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _blockerItem(t) {
|
|
||||||
const blockers = t.blockers || [];
|
|
||||||
return `
|
|
||||||
<div class="attention-item blocker">
|
|
||||||
<span class="attention-icon">🚫</span>
|
|
||||||
<div style="flex:1">
|
|
||||||
<div class="attention-name">${escHtml(t.name)}</div>
|
|
||||||
${blockers.map(b => `<div class="attention-detail">• ${escHtml(b)}</div>`).join('')}
|
|
||||||
<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">Modified ${formatDate(t.adobe_modified)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="attention-action" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
|
|
||||||
<button class="btn btn-secondary btn-xs btn-view-template" data-id="${escHtml(t.adobe_id)}">View Detail</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _warningItem(t) {
|
|
||||||
const warnings = t.warnings || [];
|
|
||||||
return `
|
|
||||||
<div class="attention-item warning">
|
|
||||||
<span class="attention-icon">⚠️</span>
|
|
||||||
<div style="flex:1">
|
|
||||||
<div class="attention-name">${escHtml(t.name)}</div>
|
|
||||||
${warnings.slice(0, 3).map(w => `<div class="attention-detail">• ${escHtml(w)}</div>`).join('')}
|
|
||||||
${warnings.length > 3 ? `<div class="attention-detail" style="color:var(--text-muted)">… +${warnings.length - 3} more</div>` : ''}
|
|
||||||
<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">Modified ${formatDate(t.adobe_modified)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="attention-action" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
|
|
||||||
<button class="btn btn-primary btn-xs btn-migrate-anyway" data-id="${escHtml(t.adobe_id)}">Migrate Anyway</button>
|
|
||||||
<button class="btn btn-secondary btn-xs btn-view-template" data-id="${escHtml(t.adobe_id)}">View Detail</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,365 +0,0 @@
|
||||||
// Migration workflow: options modal → progress → results view
|
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
import { state, setState } from './state.js';
|
|
||||||
import { escHtml, formatDateTime, downloadCsv } from './utils.js';
|
|
||||||
import { navigate } from './router.js';
|
|
||||||
import { refreshTemplates } from './templates.js';
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getSettings() {
|
|
||||||
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
|
|
||||||
catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Options modal ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function showOptionsModal(ids) {
|
|
||||||
if (!ids || !ids.length) return;
|
|
||||||
const settings = getSettings();
|
|
||||||
const names = ids.map(id => {
|
|
||||||
const t = state.templates.find(t => t.adobe_id === id);
|
|
||||||
return t ? t.name : id;
|
|
||||||
});
|
|
||||||
|
|
||||||
const existing = document.getElementById('migration-modal');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.id = 'migration-modal';
|
|
||||||
wrapper.innerHTML = `
|
|
||||||
<div class="modal-backdrop"></div>
|
|
||||||
<div class="modal-box">
|
|
||||||
<div class="modal-header">
|
|
||||||
<span class="modal-title">Migration Options</span>
|
|
||||||
<button class="modal-close" id="mm-close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="callout info" style="margin-bottom:16px">
|
|
||||||
<span class="callout-icon">📋</span>
|
|
||||||
<span>Migrating <strong>${ids.length}</strong> template${ids.length > 1 ? 's' : ''}:
|
|
||||||
${names.slice(0, 3).map(n => `<em>${escHtml(n)}</em>`).join(', ')}${names.length > 3 ? `… +${names.length - 3} more` : ''}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="options-panel">
|
|
||||||
<div class="options-title">Options</div>
|
|
||||||
|
|
||||||
<div class="option-row">
|
|
||||||
<button class="toggle" id="opt-dry-run" role="switch" aria-checked="false"></button>
|
|
||||||
<div class="option-body">
|
|
||||||
<div class="option-label">Dry Run</div>
|
|
||||||
<div class="option-desc">Validate and preview without creating DocuSign templates</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option-row">
|
|
||||||
<button class="toggle" id="opt-overwrite" role="switch"
|
|
||||||
aria-checked="${settings.defaultOverwrite ? 'true' : 'false'}"
|
|
||||||
class="toggle ${settings.defaultOverwrite ? 'on' : ''}"></button>
|
|
||||||
<div class="option-body">
|
|
||||||
<div class="option-label">Overwrite Existing</div>
|
|
||||||
<div class="option-desc">Update DocuSign templates that already exist (default: skip)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option-row">
|
|
||||||
<button class="toggle on" id="opt-include-docs" role="switch" aria-checked="true"></button>
|
|
||||||
<div class="option-body">
|
|
||||||
<div class="option-label">Include Documents</div>
|
|
||||||
<div class="option-desc">Embed PDFs in the DocuSign template payload</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option-row" style="align-items:center">
|
|
||||||
<div class="option-body">
|
|
||||||
<div class="option-label">Target Folder <span class="form-label-sub">(optional)</span></div>
|
|
||||||
<input type="text" class="form-input" id="opt-folder"
|
|
||||||
placeholder="e.g. Migrated Templates" style="margin-top:6px" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" id="mm-cancel">Cancel</button>
|
|
||||||
<button class="btn btn-primary" id="mm-run">Run Migration →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(wrapper);
|
|
||||||
|
|
||||||
// Wire toggles
|
|
||||||
wrapper.querySelectorAll('.toggle').forEach(btn => {
|
|
||||||
if (settings.defaultOverwrite && btn.id === 'opt-overwrite') btn.classList.add('on');
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
btn.classList.toggle('on');
|
|
||||||
btn.setAttribute('aria-checked', btn.classList.contains('on'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('mm-close').onclick = () => wrapper.remove();
|
|
||||||
document.getElementById('mm-cancel').onclick = () => wrapper.remove();
|
|
||||||
document.getElementById('mm-run').onclick = () => _startMigration(ids, wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Start migration ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function _startMigration(ids, wrapper) {
|
|
||||||
const dryRun = document.getElementById('opt-dry-run')?.classList.contains('on') || false;
|
|
||||||
const overwrite = document.getElementById('opt-overwrite')?.classList.contains('on') || false;
|
|
||||||
const includeDocs = !(document.getElementById('opt-include-docs')?.classList.contains('on') === false);
|
|
||||||
const folder = document.getElementById('opt-folder')?.value.trim() || undefined;
|
|
||||||
|
|
||||||
// Replace modal body with progress view
|
|
||||||
const body = wrapper.querySelector('.modal-body');
|
|
||||||
const footer = wrapper.querySelector('.modal-footer');
|
|
||||||
wrapper.querySelector('.modal-title').textContent = dryRun ? 'Dry Run' : 'Migrating…';
|
|
||||||
footer.innerHTML = ''; // hide footer during migration
|
|
||||||
|
|
||||||
const names = ids.map(id => {
|
|
||||||
const t = state.templates.find(t => t.adobe_id === id);
|
|
||||||
return { id, name: t ? t.name : id };
|
|
||||||
});
|
|
||||||
|
|
||||||
body.innerHTML = `
|
|
||||||
<div class="migration-progress">
|
|
||||||
<div class="progress-wrap">
|
|
||||||
<div class="progress-label">
|
|
||||||
<span id="prog-label">Starting…</span>
|
|
||||||
<span id="prog-count">0 / ${ids.length}</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar"><div class="progress-fill" id="prog-bar" style="width:0%"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-template-list" id="prog-list">
|
|
||||||
${names.map(n => `
|
|
||||||
<div class="progress-template-row" id="prog-row-${escHtml(n.id)}">
|
|
||||||
<div class="progress-template-name">${escHtml(n.name)}</div>
|
|
||||||
<span class="progress-template-status spinner spinner-sm"></span>
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const jobData = await api.migrate.batch({
|
|
||||||
source_template_ids: ids,
|
|
||||||
target_folder: folder,
|
|
||||||
options: { dry_run: dryRun, overwrite_if_exists: overwrite, include_documents: includeDocs },
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobId = jobData.job_id;
|
|
||||||
await pollJob(jobId, (progress) => {
|
|
||||||
const { completed, total } = progress.progress || { completed: 0, total: ids.length };
|
|
||||||
const pct = total > 0 ? Math.round(completed / total * 100) : 0;
|
|
||||||
document.getElementById('prog-bar') && (document.getElementById('prog-bar').style.width = pct + '%');
|
|
||||||
document.getElementById('prog-count')&& (document.getElementById('prog-count').textContent = `${completed} / ${total}`);
|
|
||||||
document.getElementById('prog-label')&& (document.getElementById('prog-label').textContent = `Migrating… ${pct}%`);
|
|
||||||
|
|
||||||
// Update per-template icons as results come in
|
|
||||||
(progress.results || []).forEach(r => {
|
|
||||||
const row = document.getElementById(`prog-row-${r.adobe_template_id}`);
|
|
||||||
if (!row) return;
|
|
||||||
const statusEl = row.querySelector('.progress-template-status');
|
|
||||||
if (!statusEl) return;
|
|
||||||
statusEl.className = 'progress-template-status';
|
|
||||||
if (r.status === 'success') statusEl.textContent = r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️';
|
|
||||||
else if (r.status === 'skipped') statusEl.textContent = '⏭';
|
|
||||||
else if (r.status === 'blocked') statusEl.textContent = '🚫';
|
|
||||||
else statusEl.textContent = '❌';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Migration done — show "View Results" button
|
|
||||||
document.getElementById('prog-label') && (document.getElementById('prog-label').textContent = 'Done!');
|
|
||||||
footer.innerHTML = `
|
|
||||||
<button class="btn btn-secondary" id="mm-close-done">Close</button>
|
|
||||||
<button class="btn btn-primary" id="mm-view-results">View Results →</button>
|
|
||||||
`;
|
|
||||||
document.getElementById('mm-close-done').onclick = () => wrapper.remove();
|
|
||||||
document.getElementById('mm-view-results').onclick = () => {
|
|
||||||
wrapper.remove();
|
|
||||||
navigate('#/results');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Refresh template list
|
|
||||||
await refreshTemplates();
|
|
||||||
state.selectedIds.clear();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
body.innerHTML += `<div class="callout error" style="margin-top:12px">
|
|
||||||
<span class="callout-icon">❌</span> Migration failed: ${escHtml(err.message)}
|
|
||||||
</div>`;
|
|
||||||
footer.innerHTML = `<button class="btn btn-secondary" id="mm-err-close">Close</button>`;
|
|
||||||
document.getElementById('mm-err-close').onclick = () => wrapper.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Poll batch job ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function pollJob(jobId, onProgress) {
|
|
||||||
const POLL_MS = 2000;
|
|
||||||
const MAX_WAIT = 300000; // 5 minutes
|
|
||||||
const started = Date.now();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tick = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.migrate.batchStatus(jobId);
|
|
||||||
if (onProgress) onProgress(data);
|
|
||||||
|
|
||||||
if (data.status === 'done' || data.status === 'complete') {
|
|
||||||
setState('lastMigrationResults', data);
|
|
||||||
resolve(data);
|
|
||||||
} else if (data.status === 'failed') {
|
|
||||||
reject(new Error('Migration job failed'));
|
|
||||||
} else if (Date.now() - started > MAX_WAIT) {
|
|
||||||
reject(new Error('Migration timed out'));
|
|
||||||
} else {
|
|
||||||
setTimeout(tick, POLL_MS);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Results view ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function renderResults() {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const results = state.lastMigrationResults;
|
|
||||||
|
|
||||||
if (!results) {
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">📊</div>
|
|
||||||
<div class="empty-state-title">No migration results yet</div>
|
|
||||||
<div class="empty-state-sub">Run a migration from the <a href="#/templates" style="color:var(--cobalt)">Templates</a> view to see results here.</div>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateResults = results.results || [];
|
|
||||||
const summary = {
|
|
||||||
created: templateResults.filter(r => r.action === 'created').length,
|
|
||||||
updated: templateResults.filter(r => r.action === 'updated').length,
|
|
||||||
skipped: templateResults.filter(r => r.status === 'skipped').length,
|
|
||||||
blocked: templateResults.filter(r => r.status === 'blocked').length,
|
|
||||||
errors: templateResults.filter(r => r.status === 'error').length,
|
|
||||||
dry_run: templateResults.filter(r => r.status === 'dry_run').length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const migratedIds = templateResults
|
|
||||||
.filter(r => r.status === 'success')
|
|
||||||
.map(r => r.adobe_template_id);
|
|
||||||
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<div class="page-title">Migration Results</div>
|
|
||||||
<div class="page-subtitle">${formatDateTime(results.completed_at || new Date().toISOString())}</div>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-export-results">⬇ Export CSV</button>
|
|
||||||
${migratedIds.length ? `<button class="btn btn-primary btn-sm" id="btn-verify-results">Verify Templates →</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary stat cards -->
|
|
||||||
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
|
|
||||||
<div class="stat-card green">
|
|
||||||
<div class="stat-label">Created</div>
|
|
||||||
<div class="stat-value">${summary.created}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card blue">
|
|
||||||
<div class="stat-label">Updated</div>
|
|
||||||
<div class="stat-value">${summary.updated}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card gray">
|
|
||||||
<div class="stat-label">Skipped</div>
|
|
||||||
<div class="stat-value">${summary.skipped}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card red">
|
|
||||||
<div class="stat-label">Blocked</div>
|
|
||||||
<div class="stat-value">${summary.blocked}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card red">
|
|
||||||
<div class="stat-label">Errors</div>
|
|
||||||
<div class="stat-value">${summary.errors}</div>
|
|
||||||
</div>
|
|
||||||
${summary.dry_run ? `<div class="stat-card gray"><div class="stat-label">Dry Run</div><div class="stat-value">${summary.dry_run}</div></div>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Per-template results -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">Per-Template Results</span>
|
|
||||||
</div>
|
|
||||||
<div id="results-list" style="padding:8px 16px">
|
|
||||||
${templateResults.map(r => _resultRow(r)).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex;gap:8px;margin-top:8px">
|
|
||||||
<a href="#/templates" class="btn btn-secondary btn-sm">← Back to Templates</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Expand/collapse result rows
|
|
||||||
document.querySelectorAll('.result-header').forEach(hdr => {
|
|
||||||
hdr.addEventListener('click', () => {
|
|
||||||
hdr.parentElement.classList.toggle('open');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export CSV
|
|
||||||
document.getElementById('btn-export-results')?.addEventListener('click', () => {
|
|
||||||
downloadCsv('migration-results.csv', templateResults.map(r => ({
|
|
||||||
name: r.adobe_template_name || r.adobe_template_id,
|
|
||||||
adobe_id: r.adobe_template_id,
|
|
||||||
docusign_id: r.docusign_template_id || '',
|
|
||||||
status: r.status,
|
|
||||||
action: r.action || '',
|
|
||||||
warnings: (r.warnings || []).join('; '),
|
|
||||||
})));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify button
|
|
||||||
document.getElementById('btn-verify-results')?.addEventListener('click', () => {
|
|
||||||
import('./verification.js').then(m => {
|
|
||||||
setState('verifyIds', migratedIds);
|
|
||||||
navigate('#/verify');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _resultRow(r) {
|
|
||||||
const icon = r.status === 'success'
|
|
||||||
? (r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️')
|
|
||||||
: (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌');
|
|
||||||
|
|
||||||
const statusBadge = r.status === 'success'
|
|
||||||
? `<span class="badge badge-green">${r.action || 'success'}</span>`
|
|
||||||
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : 'red'}">${r.status}</span>`;
|
|
||||||
|
|
||||||
const warnings = r.warnings || [];
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="result-row">
|
|
||||||
<div class="result-header">
|
|
||||||
<span class="result-icon">${icon}</span>
|
|
||||||
<span class="result-name">${escHtml(r.adobe_template_name || r.adobe_template_id)}</span>
|
|
||||||
${statusBadge}
|
|
||||||
${r.docusign_template_id ? `<span class="ds-pill" title="${escHtml(r.docusign_template_id)}">DS: ${escHtml(r.docusign_template_id.slice(0,8))}…</span>` : ''}
|
|
||||||
${warnings.length ? `<span class="result-meta">⚠ ${warnings.length} warning${warnings.length > 1 ? 's' : ''}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${warnings.length || r.error ? `
|
|
||||||
<div class="result-body">
|
|
||||||
${warnings.map(w => `<div class="result-warn-item"><span class="ri">⚠</span>${escHtml(w)}</div>`).join('')}
|
|
||||||
${r.error ? `<div class="result-warn-item" style="color:var(--error)"><span class="ri">❌</span>${escHtml(r.error)}</div>` : ''}
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
// Project / customer context — localStorage CRUD + switcher modal
|
|
||||||
|
|
||||||
import { escHtml, uuid, formatDate } from './utils.js';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'migrator_projects';
|
|
||||||
|
|
||||||
// ── Data model ─────────────────────────────────────────────────────────────
|
|
||||||
// localStorage schema:
|
|
||||||
// { active: string|null, projects: Array<{ id, name, createdAt }> }
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { active: null, projects: [] };
|
|
||||||
} catch {
|
|
||||||
return { active: null, projects: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(data) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listProjects() {
|
|
||||||
return load().projects;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getActive() {
|
|
||||||
const data = load();
|
|
||||||
return data.projects.find(p => p.id === data.active) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProject(name) {
|
|
||||||
const data = load();
|
|
||||||
const project = { id: uuid(), name: name.trim(), createdAt: new Date().toISOString() };
|
|
||||||
data.projects.push(project);
|
|
||||||
if (!data.active) data.active = project.id;
|
|
||||||
save(data);
|
|
||||||
return project;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteProject(id) {
|
|
||||||
const data = load();
|
|
||||||
data.projects = data.projects.filter(p => p.id !== id);
|
|
||||||
if (data.active === id) {
|
|
||||||
data.active = data.projects[0]?.id || null;
|
|
||||||
}
|
|
||||||
save(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setActive(id) {
|
|
||||||
const data = load();
|
|
||||||
if (data.projects.find(p => p.id === id)) {
|
|
||||||
data.active = id;
|
|
||||||
save(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Init: called on app startup ─────────────────────────────────────────────
|
|
||||||
// onUpdate callback is called whenever the active project changes
|
|
||||||
|
|
||||||
let _onUpdate = null;
|
|
||||||
|
|
||||||
export function initProject(onUpdate) {
|
|
||||||
_onUpdate = onUpdate;
|
|
||||||
onUpdate();
|
|
||||||
// Show project modal on first run if no projects exist
|
|
||||||
if (listProjects().length === 0) {
|
|
||||||
showProjectModal(onUpdate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Project switcher modal ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function showProjectModal(onUpdate) {
|
|
||||||
if (onUpdate) _onUpdate = onUpdate;
|
|
||||||
|
|
||||||
const existing = document.getElementById('project-modal');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.id = 'project-modal';
|
|
||||||
wrapper.innerHTML = `
|
|
||||||
<div class="modal-backdrop"></div>
|
|
||||||
<div class="modal-box modal-sm">
|
|
||||||
<div class="modal-header">
|
|
||||||
<span class="modal-title">Switch Project</span>
|
|
||||||
<button class="modal-close" id="pm-close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" style="padding-bottom:0">
|
|
||||||
<div class="project-list" id="pm-project-list"></div>
|
|
||||||
<div class="new-project-form">
|
|
||||||
<h4>New Project</h4>
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" class="form-input" id="pm-new-name"
|
|
||||||
placeholder="Customer name (e.g. Acme Corp)" maxlength="60" />
|
|
||||||
<div class="form-error" id="pm-error"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" id="pm-cancel">Cancel</button>
|
|
||||||
<button class="btn btn-primary" id="pm-create">Create Project</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(wrapper);
|
|
||||||
|
|
||||||
renderProjectList();
|
|
||||||
|
|
||||||
document.getElementById('pm-close').onclick = () => wrapper.remove();
|
|
||||||
document.getElementById('pm-cancel').onclick = () => wrapper.remove();
|
|
||||||
document.getElementById('pm-create').onclick = handleCreate;
|
|
||||||
document.getElementById('pm-new-name').addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') handleCreate();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focus name input
|
|
||||||
setTimeout(() => document.getElementById('pm-new-name')?.focus(), 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProjectList() {
|
|
||||||
const list = document.getElementById('pm-project-list');
|
|
||||||
const active = getActive();
|
|
||||||
const projects = listProjects();
|
|
||||||
|
|
||||||
if (!projects.length) {
|
|
||||||
list.innerHTML = `<p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
|
|
||||||
No projects yet. Create one below to get started.
|
|
||||||
</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = projects.map(p => `
|
|
||||||
<div class="project-row ${p.id === active?.id ? 'active' : ''}" data-id="${escHtml(p.id)}">
|
|
||||||
<div class="project-row-icon">${escHtml(p.name.slice(0, 2).toUpperCase())}</div>
|
|
||||||
<div style="flex:1">
|
|
||||||
<div class="project-row-name">${escHtml(p.name)}</div>
|
|
||||||
<div class="project-row-sub">Created ${formatDate(p.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
${p.id === active?.id
|
|
||||||
? '<span class="project-row-active-badge">● Active</span>'
|
|
||||||
: `<button class="btn btn-secondary btn-xs pm-delete-btn" data-id="${escHtml(p.id)}" title="Delete project">✕</button>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Activate on row click
|
|
||||||
list.querySelectorAll('.project-row').forEach(row => {
|
|
||||||
row.addEventListener('click', e => {
|
|
||||||
if (e.target.classList.contains('pm-delete-btn')) return;
|
|
||||||
activateProject(row.dataset.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete buttons
|
|
||||||
list.querySelectorAll('.pm-delete-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(btn.dataset.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateProject(id) {
|
|
||||||
setActive(id);
|
|
||||||
if (_onUpdate) _onUpdate();
|
|
||||||
const modal = document.getElementById('project-modal');
|
|
||||||
if (modal) modal.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(id) {
|
|
||||||
const project = listProjects().find(p => p.id === id);
|
|
||||||
if (!project) return;
|
|
||||||
if (!confirm(`Delete project "${project.name}"? This cannot be undone.`)) return;
|
|
||||||
deleteProject(id);
|
|
||||||
if (_onUpdate) _onUpdate();
|
|
||||||
renderProjectList();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreate() {
|
|
||||||
const input = document.getElementById('pm-new-name');
|
|
||||||
const errorEl = document.getElementById('pm-error');
|
|
||||||
const name = input?.value.trim();
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
if (errorEl) errorEl.textContent = 'Project name is required.';
|
|
||||||
input?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (errorEl) errorEl.textContent = '';
|
|
||||||
|
|
||||||
const project = createProject(name);
|
|
||||||
setActive(project.id);
|
|
||||||
if (_onUpdate) _onUpdate();
|
|
||||||
const modal = document.getElementById('project-modal');
|
|
||||||
if (modal) modal.remove();
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
// Hash-based client-side router
|
|
||||||
// Usage: navigate('#/templates') or window.location.hash = '#/templates'
|
|
||||||
|
|
||||||
import { escHtml } from './utils.js';
|
|
||||||
|
|
||||||
const _routes = {};
|
|
||||||
let _current = null;
|
|
||||||
|
|
||||||
// Register a route: router.register('#/templates', loadFn)
|
|
||||||
export function register(hash, loadFn) {
|
|
||||||
_routes[hash] = loadFn;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to a hash route
|
|
||||||
export function navigate(hash) {
|
|
||||||
window.location.hash = hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate and pass data to the view (stored temporarily)
|
|
||||||
let _routeData = null;
|
|
||||||
export function navigateWith(hash, data) {
|
|
||||||
_routeData = data;
|
|
||||||
navigate(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRouteData() {
|
|
||||||
const d = _routeData;
|
|
||||||
_routeData = null;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse route: '#/templates/abc123' → { base: '#/templates', param: 'abc123' }
|
|
||||||
function parseHash(hash) {
|
|
||||||
const clean = hash || '#/templates';
|
|
||||||
const parts = clean.split('/');
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
return { base: parts.slice(0, 3).join('/'), param: parts[3] || null };
|
|
||||||
}
|
|
||||||
return { base: clean, param: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route to the current hash
|
|
||||||
async function route() {
|
|
||||||
const { base, param } = parseHash(window.location.hash);
|
|
||||||
const key = param ? base : (window.location.hash || '#/templates');
|
|
||||||
const baseKey = base;
|
|
||||||
|
|
||||||
const loader = _routes[key] || _routes[baseKey] || _routes['#/templates'];
|
|
||||||
if (!loader) return;
|
|
||||||
|
|
||||||
_current = key;
|
|
||||||
updateActiveNav(baseKey);
|
|
||||||
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
if (outlet) outlet.classList.remove('view-enter');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loader(param);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Router error:', err);
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
if (outlet) outlet.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">⚠️</div>
|
|
||||||
<div class="empty-state-title">Failed to load view</div>
|
|
||||||
<div class="empty-state-sub">${escHtml(err.message)}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outlet) {
|
|
||||||
outlet.classList.add('view-enter');
|
|
||||||
// Remove class after animation to allow re-trigger
|
|
||||||
setTimeout(() => outlet.classList.remove('view-enter'), 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlight active nav item
|
|
||||||
function updateActiveNav(hash) {
|
|
||||||
document.querySelectorAll('.nav-item').forEach(el => {
|
|
||||||
el.classList.toggle('active', el.dataset.route === hash);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update breadcrumb
|
|
||||||
const label = document.querySelector(`.nav-item[data-route="${hash}"] .nav-label`);
|
|
||||||
const breadcrumbCurrent = document.getElementById('breadcrumb-current');
|
|
||||||
if (breadcrumbCurrent && label) {
|
|
||||||
breadcrumbCurrent.textContent = label.textContent.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init: listen for hash changes and route on load
|
|
||||||
export function init() {
|
|
||||||
window.addEventListener('hashchange', route);
|
|
||||||
// Route immediately
|
|
||||||
if (!window.location.hash || window.location.hash === '#') {
|
|
||||||
window.location.hash = '#/templates';
|
|
||||||
} else {
|
|
||||||
route();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function current() { return _current; }
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
// Settings view — verification defaults, migration defaults, connection info
|
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
import { state } from './state.js';
|
|
||||||
import { escHtml } from './utils.js';
|
|
||||||
|
|
||||||
const SETTINGS_KEY = 'migrator_settings';
|
|
||||||
|
|
||||||
export function loadSettings() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}; }
|
|
||||||
catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSettings(settings) {
|
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderSettings() {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const s = loadSettings();
|
|
||||||
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<div class="page-title">Settings</div>
|
|
||||||
<div class="page-subtitle">Configure verification defaults and migration behavior</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verification defaults -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div class="settings-section-title">Verification</div>
|
|
||||||
<div class="settings-section-sub">Default recipient for test envelopes</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-body">
|
|
||||||
<div class="setting-label">Test Recipient Name</div>
|
|
||||||
<div class="setting-desc">Pre-filled in the Send Test dialog on the Verification screen</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control" style="min-width:240px">
|
|
||||||
<input type="text" class="form-input" id="set-recipient-name"
|
|
||||||
value="${escHtml(s.testRecipientName || '')}"
|
|
||||||
placeholder="e.g. Test User" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-body">
|
|
||||||
<div class="setting-label">Test Recipient Email</div>
|
|
||||||
<div class="setting-desc">Pre-filled in the Send Test dialog</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control" style="min-width:240px">
|
|
||||||
<input type="email" class="form-input" id="set-recipient-email"
|
|
||||||
value="${escHtml(s.testRecipientEmail || '')}"
|
|
||||||
placeholder="e.g. test@example.com" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-body">
|
|
||||||
<div class="setting-label">Auto-Void Timer (hours)</div>
|
|
||||||
<div class="setting-desc">Reminder to void test envelopes after this many hours (display only — no automatic action)</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<input type="number" class="form-input" id="set-auto-void"
|
|
||||||
value="${s.autoVoidHours ?? 24}" min="1" max="168" style="width:80px" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Migration defaults -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div class="settings-section-title">Migration Defaults</div>
|
|
||||||
<div class="settings-section-sub">Pre-set options in the migration options modal</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-body">
|
|
||||||
<div class="setting-label">Overwrite Existing by Default</div>
|
|
||||||
<div class="setting-desc">When on, the Overwrite Existing toggle in the migration modal starts enabled</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<button class="toggle ${s.defaultOverwrite ? 'on' : ''}" id="set-overwrite"
|
|
||||||
role="switch" aria-checked="${!!s.defaultOverwrite}"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-body">
|
|
||||||
<div class="setting-label">Include Documents by Default</div>
|
|
||||||
<div class="setting-desc">Embed PDFs in the DocuSign template payload</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<button class="toggle ${s.defaultIncludeDocs !== false ? 'on' : ''}" id="set-include-docs"
|
|
||||||
role="switch" aria-checked="${s.defaultIncludeDocs !== false}"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection info (read-only) -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div class="settings-section-title">Connections</div>
|
|
||||||
<div class="settings-section-sub">Current platform connection status (connect via top bar)</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section-body" id="settings-conn-info">
|
|
||||||
<div style="padding:8px 0;font-size:13px;color:var(--text-muted)">Loading…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save -->
|
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
|
||||||
<button class="btn btn-primary" id="btn-save-settings">Save Settings</button>
|
|
||||||
<span id="save-confirm" style="font-size:13px;color:var(--success);display:none">✓ Saved</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Wire toggles
|
|
||||||
document.querySelectorAll('.toggle').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
btn.classList.toggle('on');
|
|
||||||
btn.setAttribute('aria-checked', btn.classList.contains('on'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save
|
|
||||||
document.getElementById('btn-save-settings')?.addEventListener('click', () => {
|
|
||||||
const updated = {
|
|
||||||
testRecipientName: document.getElementById('set-recipient-name')?.value.trim() || '',
|
|
||||||
testRecipientEmail: document.getElementById('set-recipient-email')?.value.trim() || '',
|
|
||||||
autoVoidHours: parseInt(document.getElementById('set-auto-void')?.value) || 24,
|
|
||||||
defaultOverwrite: document.getElementById('set-overwrite')?.classList.contains('on') || false,
|
|
||||||
defaultIncludeDocs: document.getElementById('set-include-docs')?.classList.contains('on') !== false,
|
|
||||||
};
|
|
||||||
saveSettings(updated);
|
|
||||||
const confirm = document.getElementById('save-confirm');
|
|
||||||
if (confirm) {
|
|
||||||
confirm.style.display = 'inline';
|
|
||||||
setTimeout(() => { confirm.style.display = 'none'; }, 2500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load connection info
|
|
||||||
_loadConnInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadConnInfo() {
|
|
||||||
const connEl = document.getElementById('settings-conn-info');
|
|
||||||
if (!connEl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.auth.status();
|
|
||||||
connEl.innerHTML = `
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">Adobe Sign</span>
|
|
||||||
<span class="conn-info-value">${data.adobe ? 'Connected' : 'Not connected'}</span>
|
|
||||||
<span class="conn-info-status">
|
|
||||||
<span class="badge ${data.adobe ? 'badge-green' : 'badge-gray'}">${data.adobe ? '● Connected' : '○ Disconnected'}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">DocuSign</span>
|
|
||||||
<span class="conn-info-value">${data.docusign ? 'Connected' : 'Not connected'}</span>
|
|
||||||
<span class="conn-info-status">
|
|
||||||
<span class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● Connected' : '○ Disconnected'}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">DocuSign Account ID</span>
|
|
||||||
<span class="conn-info-value mono">${escHtml(data.docusign_account_id || '—')}</span>
|
|
||||||
<span class="conn-info-status"></span>
|
|
||||||
</div>
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">API Environment</span>
|
|
||||||
<span class="conn-info-value mono">${escHtml(data.base_url || '—')}</span>
|
|
||||||
<span class="conn-info-status"></span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (e) {
|
|
||||||
connEl.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load connection info: ${escHtml(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// Global application state with simple pub/sub
|
|
||||||
|
|
||||||
const _listeners = {};
|
|
||||||
|
|
||||||
export const state = {
|
|
||||||
project: null, // { id, name, createdAt }
|
|
||||||
auth: {
|
|
||||||
adobe: false,
|
|
||||||
docusign: false,
|
|
||||||
adobeLabel: 'Adobe Sign',
|
|
||||||
docusignLabel: 'DocuSign',
|
|
||||||
},
|
|
||||||
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
|
|
||||||
selectedIds: new Set(),
|
|
||||||
lastMigrationResults: null, // final batch job results
|
|
||||||
issueCount: 0, // blocked template count (drives nav badge)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subscribe to state key changes: fn is called with (newValue, oldValue)
|
|
||||||
export function subscribe(key, fn) {
|
|
||||||
if (!_listeners[key]) _listeners[key] = [];
|
|
||||||
_listeners[key].push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish a state change
|
|
||||||
export function publish(key, newValue) {
|
|
||||||
const old = state[key];
|
|
||||||
state[key] = newValue;
|
|
||||||
(_listeners[key] || []).forEach(fn => {
|
|
||||||
try { fn(newValue, old); } catch (e) { console.error('state listener error', e); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience setter that publishes
|
|
||||||
export function setState(key, value) {
|
|
||||||
publish(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recompute derived values after template list updates
|
|
||||||
export function updateDerivedState() {
|
|
||||||
const blocked = state.templates.filter(t => t.blockers && t.blockers.length > 0).length;
|
|
||||||
setState('issueCount', blocked);
|
|
||||||
}
|
|
||||||
|
|
@ -1,469 +0,0 @@
|
||||||
// Templates view — filterable table with readiness badges + template detail
|
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
import { state, setState, updateDerivedState } from './state.js';
|
|
||||||
import { escHtml, formatDate, formatRelative, debounce } from './utils.js';
|
|
||||||
import { navigate } from './router.js';
|
|
||||||
|
|
||||||
// ── Readiness badge ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function readiness(t) {
|
|
||||||
if (t.blockers && t.blockers.length > 0) {
|
|
||||||
return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' };
|
|
||||||
}
|
|
||||||
if (t.status === 'migrated') {
|
|
||||||
return t.warnings && t.warnings.length > 0
|
|
||||||
? { key: 'migrated-warn', label: 'Migrated', cls: 'badge-migrated' }
|
|
||||||
: { key: 'migrated', label: 'Migrated', cls: 'badge-migrated' };
|
|
||||||
}
|
|
||||||
if (t.status === 'needs_update') {
|
|
||||||
return { key: 'needs-update', label: 'Needs Update', cls: 'badge-needs-update' };
|
|
||||||
}
|
|
||||||
if (t.warnings && t.warnings.length > 0) {
|
|
||||||
return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' };
|
|
||||||
}
|
|
||||||
return { key: 'ready', label: 'Ready', cls: 'badge-ready' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Refresh templates from API ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function refreshTemplates() {
|
|
||||||
if (!state.auth.adobe || !state.auth.docusign) {
|
|
||||||
setState('templates', []);
|
|
||||||
updateDerivedState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await api.templates.status();
|
|
||||||
setState('templates', data.templates || []);
|
|
||||||
updateDerivedState();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('refreshTemplates failed:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Templates list view ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let _filter = { search: '', status: 'all' };
|
|
||||||
let _sort = { col: 'name', dir: 'asc' };
|
|
||||||
|
|
||||||
export async function renderTemplates() {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
|
|
||||||
// Fetch if not loaded
|
|
||||||
if (!state.templates.length && state.auth.adobe && state.auth.docusign) {
|
|
||||||
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
|
|
||||||
await refreshTemplates();
|
|
||||||
}
|
|
||||||
|
|
||||||
_render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _render() {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const templates = _applyFilter(state.templates);
|
|
||||||
|
|
||||||
const counts = _statusCounts(state.templates);
|
|
||||||
const anySelected = state.selectedIds.size > 0;
|
|
||||||
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<div class="page-title">Templates</div>
|
|
||||||
<div class="page-subtitle">${state.templates.length} Adobe Sign templates</div>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-refresh-templates">↻ Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${!state.auth.adobe || !state.auth.docusign ? `
|
|
||||||
<div class="callout info">
|
|
||||||
<span class="callout-icon">ℹ️</span>
|
|
||||||
Connect both Adobe Sign and DocuSign in the top bar to load templates.
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<!-- Filter bar -->
|
|
||||||
<div class="filter-bar">
|
|
||||||
<input type="search" class="search-input" id="template-search"
|
|
||||||
placeholder="Search templates…" value="${escHtml(_filter.search)}" />
|
|
||||||
<div class="filter-tabs">
|
|
||||||
${_filterTab('all', `All <span class="tab-count">${counts.total}</span>`)}
|
|
||||||
${_filterTab('not_migrated', `Not Migrated <span class="tab-count">${counts.not_migrated}</span>`)}
|
|
||||||
${_filterTab('migrated', `Migrated <span class="tab-count">${counts.migrated}</span>`)}
|
|
||||||
${_filterTab('needs_update', `Needs Update <span class="tab-count">${counts.needs_update}</span>`)}
|
|
||||||
</div>
|
|
||||||
<div class="filter-tabs">
|
|
||||||
${_filterTab2('blocked', `Blocked <span class="tab-count">${counts.blocked}</span>`)}
|
|
||||||
${_filterTab2('caveats', `Caveats <span class="tab-count">${counts.caveats}</span>`)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bulk action toolbar -->
|
|
||||||
<div class="bulk-bar ${anySelected ? '' : 'hidden'}" id="bulk-bar">
|
|
||||||
<span class="bulk-bar-text">${state.selectedIds.size} template(s) selected</span>
|
|
||||||
<button class="btn btn-primary btn-sm" id="btn-migrate-selected">Migrate Selected →</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-clear-selection">Clear</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Templates table -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="templates-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width:34px">
|
|
||||||
<input type="checkbox" class="cb" id="select-all" title="Select all" />
|
|
||||||
</th>
|
|
||||||
${_th('name', 'Template Name')}
|
|
||||||
${_th('readiness', 'Readiness')}
|
|
||||||
${_th('warnings', 'Issues')}
|
|
||||||
${_th('adobe_modified','Last Modified')}
|
|
||||||
${_th('status', 'DS Status')}
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${templates.length
|
|
||||||
? templates.map(t => _templateRow(t)).join('')
|
|
||||||
: `<tr><td colspan="7">
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">📄</div>
|
|
||||||
<div class="empty-state-title">${state.templates.length ? 'No templates match your filter' : 'No templates found'}</div>
|
|
||||||
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}</div>
|
|
||||||
</div>
|
|
||||||
</td></tr>`
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
_bindEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _filterTab(key, label) {
|
|
||||||
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
|
|
||||||
data-filter="${key}">${label}</button>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _filterTab2(key, label) {
|
|
||||||
// readiness-based filters
|
|
||||||
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
|
|
||||||
data-filter="${key}">${label}</button>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _th(col, label) {
|
|
||||||
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
|
||||||
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _templateRow(t) {
|
|
||||||
const r = readiness(t);
|
|
||||||
const selected = state.selectedIds.has(t.adobe_id);
|
|
||||||
const warnCount = (t.warnings || []).length;
|
|
||||||
const blockCount = (t.blockers || []).length;
|
|
||||||
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 ? 'has-issues' : 'no-issues');
|
|
||||||
const issueLabel = blockCount > 0
|
|
||||||
? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}`
|
|
||||||
: (warnCount > 0 ? `⚠ ${warnCount} warning${warnCount > 1 ? 's' : ''}` : '✓ Clean');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
|
|
||||||
<td><input type="checkbox" class="cb row-cb" data-id="${escHtml(t.adobe_id)}" ${selected ? 'checked' : ''} /></td>
|
|
||||||
<td>
|
|
||||||
<div class="table-name tpl-name-link" data-id="${escHtml(t.adobe_id)}">${escHtml(t.name)}</div>
|
|
||||||
<div class="table-sub">${escHtml(t.adobe_id)}</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge ${r.cls}">${r.label}</span></td>
|
|
||||||
<td><span class="issue-count ${issueClass}">${issueLabel}</span></td>
|
|
||||||
<td>${formatRelative(t.adobe_modified)}</td>
|
|
||||||
<td>
|
|
||||||
${t.docusign_id
|
|
||||||
? `<span class="badge badge-blue" title="${escHtml(t.docusign_id)}">In DocuSign</span>`
|
|
||||||
: `<span class="badge badge-gray">Not Migrated</span>`}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="row-actions">
|
|
||||||
<button class="btn btn-ghost btn-xs btn-migrate-one" data-id="${escHtml(t.adobe_id)}" title="Migrate this template">→ Migrate</button>
|
|
||||||
<button class="btn btn-ghost btn-xs btn-view-detail" data-id="${escHtml(t.adobe_id)}" title="View details">Detail</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _statusCounts(templates) {
|
|
||||||
return {
|
|
||||||
total: templates.length,
|
|
||||||
not_migrated: templates.filter(t => t.status === 'not_migrated').length,
|
|
||||||
migrated: templates.filter(t => t.status === 'migrated').length,
|
|
||||||
needs_update: templates.filter(t => t.status === 'needs_update').length,
|
|
||||||
blocked: templates.filter(t => t.blockers && t.blockers.length > 0).length,
|
|
||||||
caveats: templates.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0).length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function _applyFilter(templates) {
|
|
||||||
let list = [...templates];
|
|
||||||
|
|
||||||
// Text search
|
|
||||||
if (_filter.search) {
|
|
||||||
const q = _filter.search.toLowerCase();
|
|
||||||
list = list.filter(t => t.name.toLowerCase().includes(q));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status / readiness filter
|
|
||||||
if (_filter.status !== 'all') {
|
|
||||||
if (_filter.status === 'blocked') {
|
|
||||||
list = list.filter(t => t.blockers && t.blockers.length > 0);
|
|
||||||
} else if (_filter.status === 'caveats') {
|
|
||||||
list = list.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0);
|
|
||||||
} else {
|
|
||||||
list = list.filter(t => t.status === _filter.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
list.sort((a, b) => {
|
|
||||||
let va = a[_sort.col] || '';
|
|
||||||
let vb = b[_sort.col] || '';
|
|
||||||
if (_sort.col === 'readiness') { va = readiness(a).key; vb = readiness(b).key; }
|
|
||||||
if (_sort.col === 'warnings') { va = (a.blockers||[]).length + (a.warnings||[]).length; vb = (b.blockers||[]).length + (b.warnings||[]).length; }
|
|
||||||
if (typeof va === 'number') return _sort.dir === 'asc' ? va - vb : vb - va;
|
|
||||||
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
|
||||||
});
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event wiring ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function _bindEvents() {
|
|
||||||
// Search
|
|
||||||
const searchEl = document.getElementById('template-search');
|
|
||||||
if (searchEl) {
|
|
||||||
searchEl.addEventListener('input', debounce(e => {
|
|
||||||
_filter.search = e.target.value;
|
|
||||||
_render();
|
|
||||||
}, 250));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter tabs
|
|
||||||
document.querySelectorAll('.filter-tab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
_filter.status = btn.dataset.filter;
|
|
||||||
_render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort headers
|
|
||||||
document.querySelectorAll('th.sortable').forEach(th => {
|
|
||||||
th.addEventListener('click', () => {
|
|
||||||
const col = th.dataset.col;
|
|
||||||
if (_sort.col === col) {
|
|
||||||
_sort.dir = _sort.dir === 'asc' ? 'desc' : 'asc';
|
|
||||||
} else {
|
|
||||||
_sort.col = col; _sort.dir = 'asc';
|
|
||||||
}
|
|
||||||
_render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkboxes
|
|
||||||
document.getElementById('select-all')?.addEventListener('change', e => {
|
|
||||||
const ids = _applyFilter(state.templates).map(t => t.adobe_id);
|
|
||||||
if (e.target.checked) { ids.forEach(id => state.selectedIds.add(id)); }
|
|
||||||
else { ids.forEach(id => state.selectedIds.delete(id)); }
|
|
||||||
_render();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.row-cb').forEach(cb => {
|
|
||||||
cb.addEventListener('change', e => {
|
|
||||||
const id = cb.dataset.id;
|
|
||||||
if (e.target.checked) state.selectedIds.add(id);
|
|
||||||
else state.selectedIds.delete(id);
|
|
||||||
_render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Migrate selected
|
|
||||||
document.getElementById('btn-migrate-selected')?.addEventListener('click', () => {
|
|
||||||
_launchMigration([...state.selectedIds]);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-clear-selection')?.addEventListener('click', () => {
|
|
||||||
state.selectedIds.clear();
|
|
||||||
_render();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Migrate individual
|
|
||||||
document.querySelectorAll('.btn-migrate-one').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => _launchMigration([btn.dataset.id]));
|
|
||||||
});
|
|
||||||
|
|
||||||
// View detail
|
|
||||||
document.querySelectorAll('.btn-view-detail, .tpl-name-link').forEach(el => {
|
|
||||||
el.addEventListener('click', () => navigate(`#/templates/${el.dataset.id}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh
|
|
||||||
document.getElementById('btn-refresh-templates')?.addEventListener('click', async () => {
|
|
||||||
await refreshTemplates();
|
|
||||||
_render();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _launchMigration(ids) {
|
|
||||||
if (!ids.length) return;
|
|
||||||
const { showOptionsModal } = await import('./migration.js');
|
|
||||||
showOptionsModal(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Template detail view ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function renderTemplateDetail(adobeId) {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const t = state.templates.find(t => t.adobe_id === adobeId);
|
|
||||||
|
|
||||||
if (!t) {
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">🔍</div>
|
|
||||||
<div class="empty-state-title">Template not found</div>
|
|
||||||
<div class="empty-state-sub"><a href="#/templates" class="btn btn-ghost btn-sm">← Back to Templates</a></div>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = readiness(t);
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<a href="#/templates" style="font-size:12px;color:var(--cobalt);text-decoration:none">← Templates</a>
|
|
||||||
<div class="page-title" style="margin-top:4px">${escHtml(t.name)}</div>
|
|
||||||
<div class="page-subtitle">Adobe ID: <span class="mono">${escHtml(t.adobe_id)}</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<span class="badge ${r.cls}">${r.label}</span>
|
|
||||||
<button class="btn btn-primary btn-sm" id="detail-migrate-btn">Migrate</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tabs" id="detail-tabs">
|
|
||||||
<div class="tab active" data-tab="overview">Overview</div>
|
|
||||||
<div class="tab" data-tab="issues">Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0
|
|
||||||
? `<span class="nav-badge" style="position:static;display:inline">${(t.blockers||[]).length + (t.warnings||[]).length}</span>` : ''}</div>
|
|
||||||
<div class="tab" data-tab="history">Migration History</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="detail-tab-content"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('detail-migrate-btn')?.addEventListener('click', () => {
|
|
||||||
import('./migration.js').then(m => m.showOptionsModal([adobeId]));
|
|
||||||
});
|
|
||||||
|
|
||||||
_bindDetailTabs(t);
|
|
||||||
_renderDetailTab(t, 'overview');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _bindDetailTabs(t) {
|
|
||||||
document.querySelectorAll('#detail-tabs .tab').forEach(tab => {
|
|
||||||
tab.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('#detail-tabs .tab').forEach(x => x.classList.remove('active'));
|
|
||||||
tab.classList.add('active');
|
|
||||||
_renderDetailTab(t, tab.dataset.tab);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderDetailTab(t, tabKey) {
|
|
||||||
const content = document.getElementById('detail-tab-content');
|
|
||||||
if (tabKey === 'overview') {
|
|
||||||
content.innerHTML = `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><span class="card-title">Template Overview</span></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">Adobe Sign ID</span>
|
|
||||||
<span class="conn-info-value">${escHtml(t.adobe_id)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">Last Modified</span>
|
|
||||||
<span class="conn-info-value">${formatDate(t.adobe_modified)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">Migration Status</span>
|
|
||||||
<span class="conn-info-value"><span class="badge badge-${t.status === 'migrated' ? 'migrated' : t.status === 'needs_update' ? 'amber' : 'gray'}">${t.status.replace('_', ' ')}</span></span>
|
|
||||||
</div>
|
|
||||||
${t.docusign_id ? `
|
|
||||||
<div class="conn-info-row">
|
|
||||||
<span class="conn-info-label">DocuSign Template ID</span>
|
|
||||||
<span class="conn-info-value mono">${escHtml(t.docusign_id)}</span>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
} else if (tabKey === 'issues') {
|
|
||||||
const blockers = t.blockers || [];
|
|
||||||
const warnings = t.warnings || [];
|
|
||||||
if (!blockers.length && !warnings.length) {
|
|
||||||
content.innerHTML = `<div class="callout success"><span class="callout-icon">✓</span>No issues found. This template is ready to migrate.</div>`;
|
|
||||||
} else {
|
|
||||||
content.innerHTML = `
|
|
||||||
${blockers.length ? `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><span class="card-title" style="color:var(--error)">🚫 Blockers (${blockers.length})</span></div>
|
|
||||||
<div class="card-body">
|
|
||||||
${blockers.map(b => `
|
|
||||||
<div class="issue-row">
|
|
||||||
<span class="issue-severity blocker">BLOCKER</span>
|
|
||||||
<div class="issue-body"><div class="issue-title">${escHtml(b)}</div></div>
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
${warnings.length ? `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Warnings (${warnings.length})</span></div>
|
|
||||||
<div class="card-body">
|
|
||||||
${warnings.map(w => `
|
|
||||||
<div class="issue-row">
|
|
||||||
<span class="issue-severity warn">WARNING</span>
|
|
||||||
<div class="issue-body"><div class="issue-title">${escHtml(w)}</div></div>
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}`;
|
|
||||||
}
|
|
||||||
} else if (tabKey === 'history') {
|
|
||||||
api.migrate.history().then(data => {
|
|
||||||
const records = (data.history || []).filter(r =>
|
|
||||||
r.adobe_template_id === t.adobe_id || r.adobe_template_name === t.name
|
|
||||||
);
|
|
||||||
if (!records.length) {
|
|
||||||
content.innerHTML = `<div class="callout info"><span class="callout-icon">ℹ️</span>No migration history for this template yet.</div>`;
|
|
||||||
} else {
|
|
||||||
content.innerHTML = `
|
|
||||||
<div class="card">
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Time</th><th>Action</th><th>Status</th><th>DocuSign ID</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${[...records].reverse().map(r => `
|
|
||||||
<tr>
|
|
||||||
<td>${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
|
|
||||||
<td>${escHtml(r.action||'—')}</td>
|
|
||||||
<td><span class="badge ${r.status==='success'?'badge-green':'badge-red'}">${r.status}</span></td>
|
|
||||||
<td class="mono">${escHtml(r.docusign_template_id||'—')}</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}).catch(() => {
|
|
||||||
content.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
// Shared utility functions
|
|
||||||
|
|
||||||
export function escHtml(str) {
|
|
||||||
return String(str ?? '').replace(/[&<>"']/g, c => ({
|
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
||||||
})[c]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDate(iso) {
|
|
||||||
if (!iso) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric', month: 'short', day: 'numeric'
|
|
||||||
});
|
|
||||||
} catch { return iso.slice(0, 10); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(iso) {
|
|
||||||
if (!iso) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString('en-US', {
|
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit'
|
|
||||||
});
|
|
||||||
} catch { return iso.slice(0, 19).replace('T', ' '); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRelative(iso) {
|
|
||||||
if (!iso) return '—';
|
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
|
||||||
const m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return 'just now';
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return formatDate(iso);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debounce(fn, ms = 300) {
|
|
||||||
let timer;
|
|
||||||
return (...args) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => fn(...args), ms);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uuid() {
|
|
||||||
return crypto.randomUUID
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
||||||
const r = Math.random() * 16 | 0;
|
|
||||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate a string to maxLen chars, appending ellipsis if needed
|
|
||||||
export function truncate(str, maxLen = 40) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First letter of a name for avatar initials
|
|
||||||
export function initials(name) {
|
|
||||||
if (!name) return '?';
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
return parts.length >= 2
|
|
||||||
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
||||||
: name.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download a string as a file
|
|
||||||
export function downloadText(filename, content, type = 'text/plain') {
|
|
||||||
const blob = new Blob([content], { type });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url; a.download = filename;
|
|
||||||
document.body.appendChild(a); a.click();
|
|
||||||
document.body.removeChild(a); URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert array of objects to CSV and download
|
|
||||||
export function downloadCsv(filename, rows) {
|
|
||||||
if (!rows.length) return;
|
|
||||||
const headers = Object.keys(rows[0]);
|
|
||||||
const csv = [
|
|
||||||
headers.join(','),
|
|
||||||
...rows.map(r => headers.map(h => JSON.stringify(r[h] ?? '')).join(','))
|
|
||||||
].join('\n');
|
|
||||||
downloadText(filename, csv, 'text/csv');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shorten a SHA-256 hash for display
|
|
||||||
export function shortHash(hash, len = 8) {
|
|
||||||
return hash ? hash.slice(0, len) : '—';
|
|
||||||
}
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
// Verification view — send test envelopes to confirm migrated templates work
|
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
import { state } from './state.js';
|
|
||||||
import { escHtml, formatDateTime } from './utils.js';
|
|
||||||
|
|
||||||
const POLL_MS = 5000;
|
|
||||||
const _envelopes = {}; // { adobeId: { envelopeId, status, sentAt, completedAt, polling } }
|
|
||||||
|
|
||||||
function getSettings() {
|
|
||||||
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
|
|
||||||
catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderVerification(preloadedIds = null) {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const ids = preloadedIds || state.verifyIds || null;
|
|
||||||
|
|
||||||
// Candidate templates: recently migrated (from state) or all migrated
|
|
||||||
const candidates = (state.templates || []).filter(t =>
|
|
||||||
t.status === 'migrated' || t.status === 'needs_update' || t.docusign_id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!state.auth.docusign) {
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
|
|
||||||
<div class="callout info"><span class="callout-icon">ℹ️</span>Connect DocuSign to send verification envelopes.</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidates.length) {
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
|
|
||||||
<div class="callout info"><span class="callout-icon">ℹ️</span>
|
|
||||||
No migrated templates yet. Run a migration first, then return here to verify.
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderVerifyView(candidates);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderVerifyView(candidates) {
|
|
||||||
const outlet = document.getElementById('router-outlet');
|
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
outlet.innerHTML = `
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<div class="page-title">Verification</div>
|
|
||||||
<div class="page-subtitle">Send test envelopes to confirm templates work end-to-end</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="callout info" style="margin-bottom:20px">
|
|
||||||
<span class="callout-icon">ℹ️</span>
|
|
||||||
Verification sends a real DocuSign envelope using each template. Test envelopes should be voided after use.
|
|
||||||
Configure default recipient in <a href="#/settings" style="color:var(--cobalt)">Settings</a>.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">Migrated Templates</span>
|
|
||||||
<span style="font-size:12px;color:var(--text-muted)">${candidates.length} templates</span>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="verify-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Template</th>
|
|
||||||
<th>DocuSign ID</th>
|
|
||||||
<th>Verification Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${candidates.map(t => _verifyRow(t, settings)).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Wire Send Test buttons
|
|
||||||
document.querySelectorAll('.btn-send-test').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const adobeId = btn.dataset.id;
|
|
||||||
const t = candidates.find(t => t.adobe_id === adobeId);
|
|
||||||
if (t) _showSendDialog(t, settings);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wire Void buttons
|
|
||||||
document.querySelectorAll('.btn-void-envelope').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
_voidEnvelope(btn.dataset.id, btn.dataset.envelopeid);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _verifyRow(t, settings) {
|
|
||||||
const env = _envelopes[t.adobe_id];
|
|
||||||
let statusCell = '<span class="badge badge-gray">Not Tested</span>';
|
|
||||||
let actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Test</button>`;
|
|
||||||
|
|
||||||
if (env) {
|
|
||||||
if (env.status === 'completed') {
|
|
||||||
statusCell = '<span class="badge badge-green">✓ Verified</span>';
|
|
||||||
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
|
||||||
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
|
||||||
} else if (env.status === 'sent' || env.status === 'delivered') {
|
|
||||||
statusCell = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
|
|
||||||
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
|
||||||
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
|
||||||
} else if (env.status === 'voided') {
|
|
||||||
statusCell = '<span class="badge badge-gray">Voided</span>';
|
|
||||||
actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Again</button>`;
|
|
||||||
} else {
|
|
||||||
statusCell = `<span class="badge badge-amber">${escHtml(env.status || 'pending')}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr id="verify-row-${escHtml(t.adobe_id)}">
|
|
||||||
<td>
|
|
||||||
<div class="table-name">${escHtml(t.name)}</div>
|
|
||||||
<div class="table-sub">${escHtml(t.adobe_id)}</div>
|
|
||||||
</td>
|
|
||||||
<td class="mono" style="font-size:11px">${t.docusign_id ? escHtml(t.docusign_id.slice(0,12)) + '…' : '—'}</td>
|
|
||||||
<td id="verify-status-${escHtml(t.adobe_id)}">${statusCell}</td>
|
|
||||||
<td id="verify-actions-${escHtml(t.adobe_id)}">${actionsCell}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showSendDialog(t, settings) {
|
|
||||||
const existing = document.getElementById('send-dialog');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.id = 'send-dialog';
|
|
||||||
wrapper.innerHTML = `
|
|
||||||
<div class="modal-backdrop"></div>
|
|
||||||
<div class="modal-box modal-sm">
|
|
||||||
<div class="modal-header">
|
|
||||||
<span class="modal-title">Send Test Envelope</span>
|
|
||||||
<button class="modal-close" id="sd-close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div style="font-size:13px;color:var(--text-muted);margin-bottom:14px">
|
|
||||||
Template: <strong>${escHtml(t.name)}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="sd-name">Recipient Name</label>
|
|
||||||
<input type="text" class="form-input" id="sd-name"
|
|
||||||
value="${escHtml(settings.testRecipientName || '')}"
|
|
||||||
placeholder="Test Recipient" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="sd-email">Recipient Email</label>
|
|
||||||
<input type="email" class="form-input" id="sd-email"
|
|
||||||
value="${escHtml(settings.testRecipientEmail || '')}"
|
|
||||||
placeholder="test@example.com" />
|
|
||||||
<div class="form-error" id="sd-error"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" id="sd-cancel">Cancel</button>
|
|
||||||
<button class="btn btn-primary" id="sd-send">Send Test →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(wrapper);
|
|
||||||
|
|
||||||
document.getElementById('sd-close').onclick = () => wrapper.remove();
|
|
||||||
document.getElementById('sd-cancel').onclick = () => wrapper.remove();
|
|
||||||
document.getElementById('sd-send').onclick = () => _sendEnvelope(t, wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _sendEnvelope(t, wrapper) {
|
|
||||||
const name = document.getElementById('sd-name')?.value.trim();
|
|
||||||
const email = document.getElementById('sd-email')?.value.trim();
|
|
||||||
const errorEl = document.getElementById('sd-error');
|
|
||||||
|
|
||||||
if (!name || !email) {
|
|
||||||
if (errorEl) errorEl.textContent = 'Recipient name and email are required.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (errorEl) errorEl.textContent = '';
|
|
||||||
|
|
||||||
const sendBtn = document.getElementById('sd-send');
|
|
||||||
sendBtn.disabled = true;
|
|
||||||
sendBtn.textContent = 'Sending…';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.verify.send(t.docusign_id || t.adobe_id, name, email);
|
|
||||||
wrapper.remove();
|
|
||||||
|
|
||||||
_envelopes[t.adobe_id] = { envelopeId: data.envelope_id, status: 'sent', sentAt: new Date().toISOString() };
|
|
||||||
_updateVerifyRow(t.adobe_id);
|
|
||||||
_startPolling(t.adobe_id, data.envelope_id);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
if (errorEl) errorEl.textContent = 'Send failed: ' + e.message;
|
|
||||||
sendBtn.disabled = false;
|
|
||||||
sendBtn.textContent = 'Send Test →';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _startPolling(adobeId, envelopeId) {
|
|
||||||
const env = _envelopes[adobeId];
|
|
||||||
if (!env || env.polling) return;
|
|
||||||
env.polling = true;
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.verify.status(envelopeId);
|
|
||||||
if (_envelopes[adobeId]) {
|
|
||||||
_envelopes[adobeId].status = data.status;
|
|
||||||
_envelopes[adobeId].completedAt = data.completed_at;
|
|
||||||
_updateVerifyRow(adobeId);
|
|
||||||
if (data.status !== 'completed' && data.status !== 'voided') {
|
|
||||||
setTimeout(poll, POLL_MS);
|
|
||||||
} else {
|
|
||||||
_envelopes[adobeId].polling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Polling error:', e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(poll, POLL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _updateVerifyRow(adobeId) {
|
|
||||||
const t = (state.templates || []).find(t => t.adobe_id === adobeId);
|
|
||||||
const env = _envelopes[adobeId];
|
|
||||||
if (!t || !env) return;
|
|
||||||
|
|
||||||
const statusEl = document.getElementById(`verify-status-${adobeId}`);
|
|
||||||
const actionsEl = document.getElementById(`verify-actions-${adobeId}`);
|
|
||||||
if (!statusEl) return;
|
|
||||||
|
|
||||||
if (env.status === 'completed') {
|
|
||||||
statusEl.innerHTML = '<span class="badge badge-green">✓ Verified</span>';
|
|
||||||
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
|
||||||
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
|
||||||
} else if (env.status === 'sent' || env.status === 'delivered') {
|
|
||||||
statusEl.innerHTML = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
|
|
||||||
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
|
|
||||||
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
|
|
||||||
} else if (env.status === 'voided') {
|
|
||||||
statusEl.innerHTML = '<span class="badge badge-gray">Voided</span>';
|
|
||||||
actionsEl.innerHTML = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(adobeId)}">Send Again</button>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-wire newly injected buttons
|
|
||||||
actionsEl.querySelectorAll('.btn-void-envelope').forEach(btn => {
|
|
||||||
btn.onclick = () => _voidEnvelope(btn.dataset.id, btn.dataset.envelopeid);
|
|
||||||
});
|
|
||||||
actionsEl.querySelectorAll('.btn-send-test').forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const settings = getSettings();
|
|
||||||
_showSendDialog(t, settings);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _voidEnvelope(adobeId, envelopeId) {
|
|
||||||
if (!confirm('Void this test envelope?')) return;
|
|
||||||
try {
|
|
||||||
await api.verify.void(envelopeId);
|
|
||||||
if (_envelopes[adobeId]) {
|
|
||||||
_envelopes[adobeId].status = 'voided';
|
|
||||||
_envelopes[adobeId].polling = false;
|
|
||||||
}
|
|
||||||
_updateVerifyRow(adobeId);
|
|
||||||
} catch (e) {
|
|
||||||
alert('Failed to void envelope: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #222;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header {
|
||||||
|
background: #1a3c5e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
#auth-bar { display: flex; gap: 12px; align-items: center; font-size: 13px; }
|
||||||
|
.auth-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.auth-badge.connected { background: #28a745; border-color: #28a745; }
|
||||||
|
.auth-badge:not(.connected):hover { background: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
/* ── Main layout ── */
|
||||||
|
main { padding: 20px 24px; }
|
||||||
|
|
||||||
|
.panel-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body { padding: 0; }
|
||||||
|
|
||||||
|
/* ── Template list ── */
|
||||||
|
.template-list { list-style: none; }
|
||||||
|
|
||||||
|
.template-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.template-item:last-child { border-bottom: none; }
|
||||||
|
.template-item:hover { background: #f9f9f9; }
|
||||||
|
.template-item.selected { background: #eef4ff; }
|
||||||
|
|
||||||
|
.template-item input[type=checkbox] { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.template-name { flex: 1; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Status badges ── */
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-migrated { background: #d4edda; color: #155724; }
|
||||||
|
.badge-needs_update { background: #fff3cd; color: #856404; }
|
||||||
|
.badge-not_migrated { background: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
|
.template-spinner { font-size: 12px; color: #888; }
|
||||||
|
|
||||||
|
/* ── Action bar ── */
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
#btn-migrate { background: #1a3c5e; color: #fff; }
|
||||||
|
#btn-migrate:not(:disabled):hover { background: #235080; }
|
||||||
|
|
||||||
|
#btn-refresh { background: #e9ecef; color: #333; }
|
||||||
|
#btn-refresh:hover { background: #dee2e6; }
|
||||||
|
|
||||||
|
#status-msg { font-size: 13px; color: #555; }
|
||||||
|
|
||||||
|
/* ── History ── */
|
||||||
|
.history-section { background: #fff; border: 1px solid #ddd; border-radius: 6px; }
|
||||||
|
.history-section .panel-header { background: #f8f9fa; }
|
||||||
|
|
||||||
|
.history-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.history-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.history-table td {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.history-table tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.empty-msg { padding: 20px; text-align: center; color: #999; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Adobe auth dialog ── */
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.dialog-box {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
width: min(500px, 90vw);
|
||||||
|
z-index: 101;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.dialog-box h2 { font-size: 16px; margin-bottom: 16px; }
|
||||||
|
.dialog-box ol { padding-left: 20px; margin-bottom: 16px; line-height: 1.7; }
|
||||||
|
.dialog-box ol a { color: #1a3c5e; }
|
||||||
|
.dialog-box input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.dialog-error { color: #c00; font-size: 12px; min-height: 18px; margin-bottom: 10px; }
|
||||||
|
.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.btn-secondary { background: #e9ecef; color: #333; }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.panel-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue