espace-paie-odentas/hub_signature_batch.html

1063 lines
No EOL
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="noindex, nofollow">
<title>Espace Signature Employeur Odentas</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0b0e14; --panel:#111623; --muted:#1a2235; --card:#141b2d; --accent:#6ee7b7;
--text:#e6e8ef; --sub:#b6bed1; --brand:#7c3aed; --warn:#f59e0b; --ok:#10b981; --err:#ef4444;
--radius:16px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
color:var(--text); background:linear-gradient(180deg,#0b0e14 0%, #0b0e14 35%, #0e1320 100%);
}
.wrap{max-width:1100px;margin:32px auto;padding:0 20px}
header{
display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;
}
.brand{display:flex;gap:12px;align-items:center}
.logo{
height:60px; width:auto; border-radius:12px; display:block; object-fit:contain;
box-shadow:none;
}
/* Swap logos depending on theme */
.logo-light{ display:none }
.logo-dark{ display:block }
body.theme-light .logo-light{ display:block }
body.theme-light .logo-dark{ display:none }
body.theme-dark .logo-dark{ display:block }
body.theme-dark .logo-light{ display:none }
h1{font-size:20px;margin:0}
.badge{font-size:12px;padding:6px 10px;border-radius:999px;background:#1e293b;color:#c4d0ff;border:1px solid #2b3650}
.sync{margin-left:8px;padding:2px 8px;border-radius:999px;border:1px solid #2b3650;font-size:11px;white-space:nowrap}
.sync.ok{border-color:#115e45;color:#bbf7d0;background:rgba(16,185,129,.08)}
.sync.warn{border-color:#7c5d15;color:#fde68a;background:rgba(245,158,11,.08)}
.panel{
background:linear-gradient(180deg,#0f1424 0%, #0d1220 100%);
border:1px solid #1f2a44;border-radius:var(--radius);padding:18px;box-shadow:0 10px 30px rgba(0,0,0,.35);
}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}
.stats-grid{grid-template-columns:repeat(5, minmax(0,1fr)); gap:12px}
.stats-grid > .col-3{grid-column:span 1}
.stats-grid > .col-4{grid-column:span 1}
.stats-grid > .col-8{grid-column:span 1}
.stats-grid .stat{padding:12px}
.stats-grid .stat .n{font-size:18px}
.stats-grid .stat .s{font-size:12px}
.col-4{grid-column:span 4}
.col-8{grid-column:span 8}
.col-3{grid-column:span 3}
.stat{display:flex;gap:14px;align-items:center;background:var(--card);border:1px solid #1f2a44;padding:14px;border-radius:14px}
.stat .pill{width:38px;height:38px;border-radius:12px;background:#1f2a44;display:grid;place-items:center}
.stat .n{font-size:22px;font-weight:700}
.filterbar{display:flex;gap:8px;align-items:center;margin-top:8px}
/* Modern theme toggle */
.theme-switch{ display:inline-block; position:relative; line-height:0 }
.theme-switch input{ position:absolute; inset:0; opacity:0; pointer-events:none }
.theme-switch .track{
width:56px; height:30px; background:#1f2a44; border:1px solid #33406a; border-radius:999px;
display:inline-flex; align-items:center; justify-content:center; position:relative; padding:3px; transition:.2s;
box-shadow:inset 0 2px 6px rgba(0,0,0,.25);
}
.theme-switch .knob{ width:24px; height:24px; border-radius:999px; background:#ffffff; position:absolute; left:3px; top:3px; transition:transform .2s ease; box-shadow:0 2px 6px rgba(0,0,0,.35) }
.theme-switch .icon{ font-size:14px; opacity:.9; pointer-events:none }
.theme-switch .icon.sun{ position:absolute; left:8px }
.theme-switch .icon.moon{ position:absolute; right:8px }
.theme-switch input:checked + .track .knob{ transform: translateX(26px) }
body.theme-light .theme-switch .track{ background:#e5e7eb; border-color:#cbd5e1; box-shadow:inset 0 2px 6px rgba(0,0,0,.08) }
body.theme-light .theme-switch .knob{ background:#ffffff }
/* Light theme variables */
body.theme-light{
--bg:#f4f6fb; --panel:#ffffff; --muted:#eef2f6; --card:#ffffff; --accent:#0ea5e9;
--text:#0f172a; --sub:#425066; --brand:#3b82f6; --warn:#b45309; --ok:#059669; --err:#b91c1c;
}
/* Global background for light */
body.theme-light{
background:linear-gradient(180deg,#f7f9fc 0%, #f6f8fb 35%, #eef2f6 100%);
}
/* Panels & cards */
body.theme-light .panel{ background:linear-gradient(180deg,#ffffff 0%, #ffffff 100%); border-color:#e6eaf2; box-shadow:0 6px 20px rgba(0,0,0,.08) }
body.theme-light .card{ background:#ffffff; border-color:#e6eaf2 }
body.theme-light .stat{ background:#ffffff; border-color:#e6eaf2 }
body.theme-light .pill{ background:#eef2f8 !important }
/* Light: emphasise Matricule & Salarié values, keep Template muted */
body.theme-light .card .meta{ color:#516070 } /* labels */
body.theme-light .card .meta .emph-val{ color:#0f172a !important } /* valeurs plus foncées mais moins dures */
body.theme-light .card .meta code,
body.theme-light .card .meta .muted-val{ color:#6b7280 !important } /* template un peu plus lisible mais discret */
/* Text tweaks */
body.theme-light h1, body.theme-light .title, body.theme-light .ref{ color:#0f172a }
body.theme-light .ref{ color:#334155; }
/* Inputs & buttons */
body.theme-light input[type="text"]{ background:#ffffff; border:1px solid #e6eaf2; color:#0f172a }
body.theme-light .btn{ background:#ffffff; color:#0f172a; border:1px solid #e6eaf2 }
body.theme-light .btn.sub{ background:#ffffff; border:1px solid #e6eaf2 }
/* Status chips */
body.theme-light .status{ background:#f1f5f9; border-color:#e5e7eb; color:#0b1220 }
body.theme-light .status.ok{ border-color:#86efac; background:#ecfdf5; color:#065f46 }
body.theme-light .status.warn{ border-color:#fde68a; background:#fffbeb; color:#92400e }
body.theme-light .status.err{ border-color:#fecaca; background:#fef2f2; color:#7f1d1d }
/* Header badges */
body.theme-light .badge{ background:#eef2f8; color:#0b1220; border-color:#e5e7eb }
body.theme-light .sync.ok{ border-color:#86efac; color:#065f46; background:#ecfdf5 }
body.theme-light .sync.warn{ border-color:#facc15; color:#92400e; background:#fffbeb }
/* Tooltips */
body.theme-light .tt[data-tip]::after{ background:#ffffff; color:#0b1220; border-color:#e5e7eb }
body.theme-light .tt[data-tip]::before{ border-top-color:#e5e7eb }
/* Dialogs */
body.theme-light dialog{ background:#ffffff; color:#0b1220; border-color:#e5e7eb; box-shadow:0 24px 60px rgba(0,0,0,.15) }
body.theme-light .modal-head{ background:#f8fafc; border-bottom-color:#e5e7eb }
body.theme-light .modal-body{ background:#ffffff }
body.theme-light .close{ color:#475569 }
/* Contact form fields */
body.theme-light select, body.theme-light textarea{ background:#ffffff !important; border:1px solid #e5e7eb !important; color:#0b1220 !important }
/* Page loader & overlays */
body.theme-light #page-loader{ background:rgba(255,255,255,.72) }
body.theme-light #page-loader > div{ background:#ffffff; border-color:#e5e7eb }
/* Inactivity overlay theming (light) */
body.theme-light #poll-timeout-overlay{ background:rgba(255,255,255,.72) }
body.theme-light #poll-timeout-overlay .inact-box{ background:#ffffff; border-color:#e5e7eb }
body.theme-light #poll-timeout-overlay .inact-head{ background:#f8fafc; border-bottom-color:#e5e7eb; color:#0b1220; border-top-left-radius:16px; border-top-right-radius:16px }
body.theme-light #poll-timeout-overlay .inact-head .pill{ background:#eef2f8 !important; color:#0b1220 }
body.theme-light #poll-timeout-overlay .inact-body{ color:#0b1220 }
body.theme-light #poll-timeout-overlay .inact-foot{ border-top-color:#e5e7eb; color:#475569 }
input[type="text"]{
background:#0e1320;border:1px solid #1f2a44;color:var(--text);padding:10px 12px;border-radius:10px;outline:none;width:240px
}
.btn{
appearance:none;border:0;background:#222b45;color:#e5e9ff;padding:10px 14px;border-radius:10px;
cursor:pointer;border:1px solid #33406a;transition:.2s; font-weight:600
}
.btn:hover{transform:translateY(-1px);box-shadow:0 8px 20px rgba(0,0,0,.25)}
.btn:disabled{ cursor:not-allowed !important; opacity:.75 }
.btn:disabled:hover{ transform:none; box-shadow:none }
/* Loader spinner */
.spinner{ width:36px; height:36px; border-radius:999px; border:3px solid rgba(203,213,225,.25); border-top-color:#6ee7b7; animation:spin 0.8s linear infinite }
@keyframes spin{ to { transform: rotate(360deg) } }
/* Modern tooltips */
.tt{ position:relative; display:inline-flex; align-items:center }
.tt[data-tip]{ cursor:help }
.tt[data-tip]::after{
content: attr(data-tip);
position:absolute; right:0; bottom:calc(100% + 10px);
background:#0b1220; color:#e6e8ef; border:1px solid #283252;
padding:8px 10px; border-radius:8px; font-size:12px; line-height:1.35;
box-shadow:0 12px 30px rgba(0,0,0,.35);
max-width:280px; width:max-content; z-index:1000;
opacity:0; transform:translateY(4px); pointer-events:none; transition:opacity .15s ease, transform .15s ease;
white-space:normal;
}
.tt[data-tip]::before{
content:""; position:absolute; right:12px; bottom:100%;
border:6px solid transparent; border-top-color:#283252; transform:translateY(0);
opacity:0; transition:opacity .15s ease;
}
.tt[data-tip]:hover::after,
.tt[data-tip]:hover::before,
.tt[data-tip]:focus-within::after,
.tt[data-tip]:focus-within::before{ opacity:1; transform:translateY(0) }
/* Inactivity overlay default (dark theme) */
#poll-timeout-overlay{ background:rgba(7,10,18,.72); backdrop-filter:blur(2px) }
#poll-timeout-overlay .inact-box{ background:#0f1424; border:1px solid #1f2a44; border-radius:16px; box-shadow:0 20px 60px rgba(0,0,0,.55) }
#poll-timeout-overlay .inact-head{ background:#0f1424; border-bottom:1px solid #1f2a44; color:#e6e8ef; border-top-left-radius:16px; border-top-right-radius:16px; position:relative; z-index:1 }
#poll-timeout-overlay .inact-body{ color:#cbd5e1 }
#poll-timeout-overlay .inact-foot{ color:#94a3b8; border-top:1px solid #1f2a44 }
.btn.accent{background:linear-gradient(90deg,#6ee7b7 0,#3b82f6 100%);color:#091123;border:0}
.btn.sub{background:#0f172a;border:1px solid #293354}
.fab{
position:absolute;right:12px;bottom:12px;width:38px;height:38px;
border-radius:999px;display:grid;place-items:center;
border:1px solid #33406a;background:#0f172a;color:#cbd5e1;cursor:pointer
}
.fab:hover{transform:translateY(-1px);box-shadow:0 8px 20px rgba(0,0,0,.25)}
/* Light theme variant for contact button */
body.theme-light .fab{
background:#ffffff;
color:#0f172a;
border:1px solid #e6eaf2;
box-shadow:0 4px 12px rgba(0,0,0,.06);
}
body.theme-light .fab:hover{
transform:translateY(-1px);
box-shadow:0 8px 18px rgba(0,0,0,.10);
}
.fab svg{width:18px;height:18px;display:block}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:16px;margin-top:16px}
.card{
background:linear-gradient(180deg,#12182a 0%, #0f1424 100%);
border:1px solid #1f2a44;border-radius:16px;padding:14px;
display:flex;flex-direction:column;gap:12px;position:relative;
}
.row{display:flex;justify-content:space-between;align-items:center;gap:8px}
.title{font-weight:500}
.ref{color:#aab6ff;font-weight:500;line-height:1.2;margin-top:2px}
.card .emph-val{ font-weight:500 }
.status{font-size:12px;padding:6px 10px;border-radius:999px;border:1px solid #2b3650;background:#0e1628;color:#cbd5e1;white-space:nowrap}
.status.ok{border-color:#115e45;color:#bbf7d0;background:rgba(16,185,129,.08)}
.status.warn{border-color:#7c5d15;color:#fde68a;background:rgba(245,158,11,.08)}
.status.err{border-color:#7a1f23;color:#fecaca;background:rgba(239,68,68,.08)}
.empty{opacity:.7;padding:16px;text-align:center}
/* modal */
dialog{
width:min(900px,95vw);border:1px solid #2b3650;border-radius:16px;background:#0d1220;color:var(--text);
box-shadow:0 20px 60px rgba(0,0,0,.55);padding:0;overflow:hidden
}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;border-bottom:1px solid #1f2a44;background:#0f1424}
.modal-body{padding:0;height:min(80vh,720px);background:#0a0f1a;overflow:auto}
iframe{width:100%;height:100%;border:0}
docuseal-form{display:block;width:100%;min-height:100%}
.close{background:transparent;border:0;color:#aab6d6;font-size:22px;cursor:pointer}
footer{margin-top:20px;text-align:center;color:#7e8bb6;font-size:12px;opacity:.8}
@media (max-width:860px){.col-4,.col-8{grid-column:span 12}}
@media (max-width:1024px){.stats-grid{grid-template-columns:repeat(3, minmax(0,1fr))}}
@media (max-width:640px){.stats-grid{grid-template-columns:repeat(2, minmax(0,1fr))}}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<!-- Logo versions: dark & light -->
<img class="logo logo-dark" src="https://newstaging.odentas.fr/wp-content/uploads/2025/09/Odentas-Logo-Bleu-Fond-Transparent-5.png" alt="Logo Odentas (sombre)" />
<img class="logo logo-light" src="https://newstaging.odentas.fr/wp-content/uploads/2025/08/Odentas-Logo-Bleu-Fond-Transparent-4-1.png" alt="Logo Odentas (clair)" />
<div>
<h1 id="page-title">Espace Signature Employeur Odentas<span id="employer-name" class="badge" style="margin-left:8px; display:none"></span></h1>
<div id="batch-sub" class="badge">
Chargement… <span id="sync-status" class="sync ok" style="display:none">Synchronisation activée</span>
</div>
</div>
</div>
<div class="filterbar">
<label class="theme-switch" aria-label="Basculer le thème clair/sombre">
<input id="theme-toggle" type="checkbox" />
<span class="track">
<span class="knob"></span>
<span class="icon sun" aria-hidden="true">🌞</span>
<span class="icon moon" aria-hidden="true">🌙</span>
</span>
</label>
</div>
</header>
<section class="panel">
<div class="grid stats-grid">
<div class="col-3">
<div class="stat">
<div class="pill">🗂️</div>
<div><div class="n" id="stat-total"></div><div class="s">Contrats dans le batch</div></div>
</div>
</div>
<div class="col-3">
<div class="stat">
<div class="pill">✍️</div>
<div><div class="n" id="stat-ready"></div><div class="s">À signer par l'employeur</div></div>
</div>
</div>
<div class="col-3">
<div class="stat">
<div class="pill"></div>
<div><div class="n" id="stat-done"></div><div class="s">Signés par l'employeur</div></div>
</div>
</div>
<div class="col-3">
<div class="stat">
<div class="pill">🧾</div>
<div><div class="n" id="stat-emp-todo"></div><div class="s">À signer par les salariés</div></div>
</div>
</div>
<div class="col-3">
<div class="stat">
<div class="pill">🏁</div>
<div><div class="n" id="stat-finished"></div><div class="s">Signés par toutes les parties</div></div>
</div>
</div>
</div>
</section>
<section class="panel" style="margin-top:16px">
<div id="cards" class="cards"></div>
<div id="empty" class="empty" style="display:none">Aucun contrat trouvé pour ce batch.</div>
</section>
<footer>© Odentas Media SAS 2021 - 2025 | Tous droits réservés.</footer>
</div>
<dialog id="dlg-contact">
<div class="modal-head">
<strong>Contacter le gestionnaire</strong>
<button class="close" aria-label="Fermer" onclick="qs('#dlg-contact').close()">×</button>
</div>
<div class="modal-body" style="padding:16px;">
<form id="contact-form" style="display:grid;gap:12px">
<div style="font-size:12px;color:#aab6d6">
Contrat&nbsp;: <strong id="contact-ref"></strong> · Matricule&nbsp;: <strong id="contact-mat"></strong>
</div>
<div style="display:grid;gap:6px">
<label for="contact-reason" style="font-size:12px;color:#aab6d6">Motif</label>
<select id="contact-reason" required
style="background:#0e1320;border:1px solid #1f2a44;color:#e6e8ef;padding:10px;border-radius:10px">
<option value="Erreur">Signaler une erreur</option>
<option value="Modification">Demander une modification</option>
<option value="Annulation">Demander une annulation</option>
<option value="Avenant">Demander un avenant</option>
<option value="Autre">Autre</option>
</select>
</div>
<div style="display:grid;gap:6px">
<label for="contact-msg" style="font-size:12px;color:#aab6d6">Votre message</label>
<textarea id="contact-msg" rows="6" placeholder="Décrivez votre demande..."
style="background:#0e1320;border:1px solid #1f2a44;color:#e6e8ef;padding:10px;border-radius:10px"></textarea>
</div>
<div style="font-size:12px;color:#aab6d6">
Message envoyé à votre gestionnaire: <strong id="contact-to"></strong>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:6px">
<button type="button" class="btn sub" onclick="qs('#dlg-contact').close()">Annuler</button>
<button type="submit" class="btn accent">Envoyer</button>
</div>
</form>
</div>
</dialog>
<dialog id="dlg">
<div class="modal-head">
<strong id="dlg-title">Signature</strong>
<button class="close" aria-label="Fermer" onclick="dlg.close()">×</button>
</div>
<div class="modal-body" id="dlg-body"></div>
</dialog>
<!-- Page loader overlay -->
<div id="page-loader" style="display:none;position:fixed;inset:0;background:rgba(7,10,18,.82);backdrop-filter:blur(2px);z-index:10000">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;gap:14px;background:#0f1424;border:1px solid #1f2a44;border-radius:16px;padding:24px 28px;box-shadow:0 20px 60px rgba(0,0,0,.55);min-width:260px">
<div class="spinner" aria-hidden="true"></div>
<div id="page-loader-text" style="color:#cbd5e1">Connexion aux serveurs Odentas…</div>
<div id="page-loader-subtext" style="color:#94a3b8;font-size:12px;margin-top:4px;display:none">Cest un peu plus long que dhabitude… merci de patienter quelques secondes.</div>
</div>
</div>
<!-- Overlay d'inactivité / fin de polling -->
<div id="poll-timeout-overlay" style="display:none;position:fixed;inset:0;backdrop-filter:blur(2px);z-index:9999">
<div class="inact-box" style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(520px,92vw);">
<div class="inact-head" style="padding:14px 16px;display:flex;align-items:center;gap:10px">
<div class="pill" style="background:#1f2a44;width:34px;height:34px;border-radius:10px;display:grid;place-items:center">⏸️</div>
<div style="font-weight:700">Synchronisation en pause</div>
</div>
<div class="inact-body" style="padding:16px">
<p style="margin:0 0 10px 0">La synchronisation avec nos serveurs est en pause.</p>
<p style="margin:0 0 16px 0">Cliquez sur <strong>Réactiver la synchronisation</strong> ou n'importe où sur la page pour reprendre le suivi automatique.</p>
<div style="display:flex;gap:8px;justify-content:center">
<button id="btn-resume-poll" class="btn accent">Réactiver la synchronisation</button>
</div>
</div>
<div class="inact-foot" style="padding:10px 16px;font-size:12px;line-height:1.4">
🌿 Pour limiter les connexions répétées et économiser de lénergie, la synchronisation se met en pause après un moment dinactivité ou lorsque longlet est en arrièreplan.
<br><br>L'Espace Paie Odentas est hébergé dans un data-center situé en Union Européenne et alimenté à 100% en énergies vertes.
</div>
</div>
</div>
<script async src="https://cdn.docuseal.com/js/form.js"></script>
<script>
/* ================================
CONFIG (remplacer pour vos tests)
================================= */
const CONFIG = {
AIRTABLE: {
BASE_ID: 'appyVHP0ZQdQzXFPn', // ← Base ID
TABLE_CONTRATS: 'Contrats de travail', // ← Nom table
TABLE_BATCHES: 'Batches Signature', // ← Nom table
API_KEY: 'patrNer5Dqyff2KEJ.aeebec95fc07db378dc92867d81ceba64e5e625b76ab0153395172595d8aa50c' // ← ⚠️ clé perso (évitez côté client)
},
PROXY: {
DS_BASE: 'https://bsezjh3d43.execute-api.eu-west-3.amazonaws.com/default/docuseal' // ← mets ici l'URL de ta Lambda/API Gateway
},
POLL_INTERVAL_MS: 8000, // rafraîchit toutes les 8s (ajuste si besoin)
POLL_MAX_MS: 300000, // ⏳ durée max de polling (10 min). Met 0 pour désactiver l'arrêt auto
CONTACT: {
WEBHOOK_URL: 'https://n8n.odentas.fr/webhook-test/947d5d4e-7301-42f6-810a-9136eaac84f4', // ← colle ici l'URL de ton webhook n8n (Public Webhook)
SECRET: '' // ← optionnel: secret partagé; sera envoyé en Authorization: Bearer <SECRET>
}
};
window.CONFIG = CONFIG;
/* ================================
THEME (light/dark with persistence)
================================= */
function getSystemPref(){
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark';
}
function applyTheme(t){
const body = document.body;
body.classList.toggle('theme-light', t==='light');
body.classList.toggle('theme-dark', t==='dark');
const chk = document.getElementById('theme-toggle');
if (chk) chk.checked = (t==='light');
}
function initTheme(){
const saved = localStorage.getItem('hub_theme');
const theme = saved || getSystemPref();
applyTheme(theme);
}
function toggleTheme(){
const isLight = document.body.classList.contains('theme-light');
const next = isLight ? 'dark' : 'light';
applyTheme(next);
localStorage.setItem('hub_theme', next);
}
/* ================================
HELPERS
================================= */
function showPageLoader(message){
const el = qs('#page-loader'); if (el) el.style.display = 'block';
const t = qs('#page-loader-text'); if (t && message) t.textContent = message;
const st = qs('#page-loader-subtext'); if (st) st.style.display = 'none';
}
function hidePageLoader(){
const el = qs('#page-loader'); if (el) el.style.display = 'none';
}
// Affichage immédiat (pas de délai), accepte un message
function showPageLoaderDelayed(message){
showPageLoader(message);
}
function clearPageLoaderDelayed(){
if (window.__loaderTimer){ clearTimeout(window.__loaderTimer); window.__loaderTimer = null; }
if (window.__loaderSubTimer){ clearTimeout(window.__loaderSubTimer); window.__loaderSubTimer = null; }
const st = qs('#page-loader-subtext'); if (st) st.style.display = 'none';
hidePageLoader();
}
function scheduleLoaderSubtext(delayMs = 3500){
if (window.__loaderSubTimer) clearTimeout(window.__loaderSubTimer);
window.__loaderSubTimer = setTimeout(()=>{
const st = qs('#page-loader-subtext'); if (st) st.style.display = 'block';
}, delayMs);
}
async function refreshNow(batchId, message = 'Reconnexion en cours…'){
try{
showPageLoaderDelayed(message);
scheduleLoaderSubtext(3500); // affiche une phrase de patience si c'est un peu long
await loadAndRender(batchId);
} finally {
clearPageLoaderDelayed();
}
}
const qs = (sel, el=document)=>el.querySelector(sel);
const qsa = (sel, el=document)=>[...el.querySelectorAll(sel)];
const escapeAirtable = s => String(s).replace(/'/g, "\\'");
const asHeadersAirtable = { 'Authorization': `Bearer ${CONFIG.AIRTABLE.API_KEY}` };
const asHeadersJSON = { 'Content-Type':'application/json' };
const dlg = document.getElementById('dlg');
function openDlgSafely() {
if (dlg && typeof dlg.showModal === 'function') {
dlg.showModal();
} else if (dlg) {
dlg.setAttribute('open', '');
}
}
window.addEventListener('load', () => {
const ok = (window.customElements && customElements.get('docuseal-form')) ? 'ready' : 'pending';
console.log('[Hub] DocuSeal web component:', ok);
});
console.log('[Hub] Contact webhook:', CONFIG.CONTACT.WEBHOOK_URL ? 'set' : 'not set');
function paramsFromUrl() {
const u = new URL(location.href);
// 1) Query params (case/variant tolerant)
let batchId =
u.searchParams.get('batchId') ||
u.searchParams.get('batchID') ||
u.searchParams.get('batch_id') ||
null;
// 2) Hash fallback (#batchId=... or #batchID=...)
if (!batchId && u.hash) {
const m = u.hash.match(/(?:batchId|batchID|batch_id)=([^&]+)/);
if (m) batchId = decodeURIComponent(m[1]);
}
// 3) Path fallback (dernier segment) UNIQUEMENT si ça ressemble à un ID
if (!batchId) {
const last = (u.pathname.split('/').filter(Boolean).pop() || '').trim();
const isHtml = /\.html?$/i.test(last);
const looksLikeId = /^[0-9a-fA-F-]{20,}$/.test(last) || /^[A-Za-z0-9_-]{12,}$/.test(last);
if (!isHtml && looksLikeId && !/^hub_?signatures?$/i.test(last)) {
batchId = last;
}
}
return {
batchId: batchId || null,
token: u.searchParams.get('token') || u.searchParams.get('t') || null
};
}
function fmtStatusEmployeur(val){
if(!val) return {label:'—',cls:''};
const n = String(val).toLowerCase();
if(n==='oui') return {label:'Signé par employeur', cls:'ok'};
if(n==='non') return {label:'En attente employeur', cls:'warn'};
return {label:val, cls:''};
}
function setSyncStatus(state){
const el = qs('#sync-status');
if (!el) return;
if (state === 'paused'){
el.textContent = 'Synchronisation en pause';
el.classList.remove('ok'); el.classList.add('warn');
el.style.display = 'inline-block';
} else {
el.textContent = 'Synchronisation activée';
el.classList.remove('warn'); el.classList.add('ok');
el.style.display = 'inline-block';
}
}
/* ================================
AIRTABLE API
================================= */
async function fetchAirtable(path){
const url = `https://api.airtable.com/v0/${CONFIG.AIRTABLE.BASE_ID}/${encodeURIComponent(path)}`;
const res = await fetch(url, { headers: asHeadersAirtable });
if(!res.ok) throw new Error('Airtable error '+res.status);
return res.json();
}
async function getBatchByBatchId(batchId){
const formula = `({batch_id}='${escapeAirtable(batchId)}')`;
const url = `https://api.airtable.com/v0/${CONFIG.AIRTABLE.BASE_ID}/${encodeURIComponent(CONFIG.AIRTABLE.TABLE_BATCHES)}?maxRecords=1&filterByFormula=${encodeURIComponent(formula)}`;
const res = await fetch(url, { headers: asHeadersAirtable });
if(!res.ok) throw new Error('Airtable batch error '+res.status);
const data = await res.json();
return data.records?.[0] || null;
}
async function getContratsForBatch(batchId, linkedIds=null){
// Méthode A (champ batch_id sur Contrats)
const formulaA = `({batch_id}='${escapeAirtable(batchId)}')`;
// Méthode B (à partir de la liste liée 'contrats' sur le batch)
const filterIds = (linkedIds||[]).map(id => `RECORD_ID()='${id}'`).join(',');
const formulaB = filterIds ? `OR(${filterIds})` : null;
const formula = formulaB || formulaA;
const url = `https://api.airtable.com/v0/${CONFIG.AIRTABLE.BASE_ID}/${encodeURIComponent(CONFIG.AIRTABLE.TABLE_CONTRATS)}?filterByFormula=${encodeURIComponent(formula)}`;
const res = await fetch(url, { headers: asHeadersAirtable });
if(!res.ok) throw new Error('Airtable contrats error '+res.status);
const data = await res.json();
return data.records || [];
}
/* ================================
DOCUSEAL API
================================= */
async function dsFetch(path, opts={}){
const url = `${CONFIG.DOCUSEAL.API_URL}${path}`;
const res = await fetch(url, {
...opts,
headers: { 'X-Auth-Token': CONFIG.DOCUSEAL.TOKEN, ...(opts.headers||{}) }
});
if(!res.ok){
let msg = res.status+' '+res.statusText;
try{ const j = await res.json(); msg += ' - '+(j.error || JSON.stringify(j)); }catch{}
throw new Error('DocuSeal: '+msg);
}
return res.json();
}
async function dsProxy(path, opts = {}) {
const base = CONFIG.PROXY?.DS_BASE || '';
const url = `${base}${path}`;
const res = await fetch(url, { ...opts });
if (!res.ok) {
let msg = res.status+' '+res.statusText;
try { const j = await res.json(); msg += ' - '+(j.error || JSON.stringify(j)); } catch {}
throw new Error('DocuSeal Proxy: ' + msg);
}
return res.json();
}
// Récupère la soumission pour un template, spécifique au rôle Employeur
async function getEmployerSubmissionByTemplate(templateId){
// Utilise directement /templates/:id/submissions (il n'y aura jamais plusieurs soumissions par template)
const resp = await dsProxy(`/templates/${templateId}/submissions`)
const sub = (resp?.data && Array.isArray(resp.data)) ? resp.data[0] : (Array.isArray(resp) ? resp[0] : resp);
if (!sub || !sub.id) return null;
// Récupère le détail pour obtenir l'URL d'embed ou le slug
const detail = await dsProxy(`/submissions/${sub.id}`);
const roles = detail.submitters || detail.roles || [];
const employer = roles.find(r => (r.role||r.name)==='Employeur') || {};
// Si un slug est présent pour le rôle Employeur, construit l'URL publique
let embed = null;
if (employer.slug) {
embed = `https://docuseal.eu/s/${employer.slug}`;
} else {
embed = employer.embed_src || employer.sign_src || detail.embed_src || null;
}
return { id: sub.id, embed_src: embed };
}
/* ================================
RENDER
================================= */
function renderBatchHeader(batch, nb, nbReady, nbDone, nbEmpTodo, nbFinished){
const structure = batch?.fields?.['Nom structure'] || batch?.fields?.['Structure'] || '—';
// Employer name logic
const employer = batch?.fields?.['employeur_api'] || batch?.fields?.['Employeur'] || batch?.fields?.['Client'] || null;
const managerEmail = 'paie@odentas.fr';
window.__managerEmail = managerEmail;
const empEl = qs('#employer-name');
if (empEl) {
if (employer) { empEl.textContent = employer; empEl.style.display='inline-block'; }
else { empEl.style.display='none'; }
}
const batchHtml = `Batch ${batch?.fields?.batch_id || '—'} · ${structure} <span id="sync-status" class="sync ok" style="display:none">Synchronisation activée</span>`;
qs('#batch-sub').innerHTML = batchHtml;
setSyncStatus('active');
qs('#stat-total').textContent = nb;
qs('#stat-ready').textContent = nbReady;
qs('#stat-done').textContent = nbDone;
qs('#stat-finished').textContent = nbFinished;
qs('#stat-emp-todo').textContent = nbEmpTodo;
}
function cardTemplate(c){
const f = c.fields || {};
const ref = f.Reference || '—';
const mat = f['Matricule API'] || f.Matricule || '—';
const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—';
const st = fmtStatusEmployeur(f['Contrat signé par employeur']);
const isSigned = String(f['Contrat signé par employeur']||'').toLowerCase() === 'oui';
const btnDisabled = !f._employer_embed || isSigned;
const salarieOK = String(f['Contrat signé']||'').toLowerCase()==='oui';
let stFinal = st;
if (isSigned && salarieOK) {
stFinal = { label: 'Signé par salarié', cls: 'ok' };
}
return `
<div class="card" data-id="${c.id}">
<div class="row">
<div>
<div class="title">Contrat</div>
<div class="ref">${ref}</div>
</div>
<div class="status ${stFinal.cls}">${stFinal.label}</div>
</div>
<div class="row meta" style="color:#aab6d6">
<div>Matricule&nbsp;: <strong class="emph-val">${mat}</strong></div>
<div>Template&nbsp;: <code class="muted-val">${f.docuseal_template_id || '—'}</code></div>
</div>
<div class="row meta" style="color:#aab6d6">
<div>Salarié&nbsp;: <strong class="emph-val">${nom}</strong></div>
<div></div>
</div>
<div class="row">
${btnDisabled ? `
<span class="tt" data-tip="Ce contrat a été envoyé à votre salarié, et sera disponible au téléchargement après réception de toutes les signatures" tabindex="0">
<button class="btn" data-open="${c.id}" disabled>Déjà signé</button>
</span>
` : `
<button class="btn" data-open="${c.id}">Signer (Employeur)</button>
`}
<button class="fab contact-fab" title="Contacter le gestionnaire" data-contact="${c.id}">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M2 5a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H9.5l-3.7 3.2A1 1 0 0 1 4 20.7V18H5a3 3 0 0 1-3-3V5Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2v1.6l2.6-2.3a1 1 0 0 1 .7-.3H19a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5Z"/>
</svg>
</button>
</div>
</div>
`;
}
function bindCardActions(records){
const byId = Object.fromEntries(records.map(r=>[r.id, r]));
qsa('[data-open]').forEach(el=>{
el.addEventListener('click', e=>{
e.preventDefault();
const id = el.getAttribute('data-open');
const rec = byId[id];
const f = rec?.fields || {};
if(!f._employer_embed){
alert("Lien d'embed indisponible pour ce contrat.");
return;
}
const title = `Signature (Employeur) · ${f.Reference || id}`;
qs('#dlg-title').textContent = title;
const src = f._employer_embed;
// Web component DocuSeal (évite X-Frame-Options)
qs('#dlg-body').innerHTML = `
<docuseal-form
data-src="${src}"
data-language="fr"
data-with-title="false"
data-background-color="#fff"
data-custom-css="
/* Exemples : adapte selon tes besoins/branding */
/* Couleur du bouton Valider */
#submit_form_button { background-color: #6ee7b7 !important; color: #0b1220 !important; }
/* Barre den-tête interne du formulaire */
.ds-form-header { background:#fff !important; color:#0b1220 !important; }
.ds-form-header .title, .ds-form-header .subtitle { color:#0b1220 !important; }
/* Icônes & textes secondaires */
.ds-text-muted { color:#475569 !important; }
">
</docuseal-form>
`;
// Fallback si le composant n'est pas prêt
setTimeout(() => {
const ready = window.customElements && customElements.get('docuseal-form');
const present = qs('docuseal-form', qs('#dlg-body'));
if (!ready || !present) {
console.warn('[Hub] docuseal-form indisponible, ouverture dun nouvel onglet');
window.open(src, '_blank', 'noopener');
}
}, 250);
openDlgSafely();
});
});
}
function openContactDialog(rec){
const f = rec?.fields || {};
qs('#contact-ref').textContent = f.Reference || rec.id;
qs('#contact-mat').textContent = f['Matricule API'] || f.Matricule || '—';
qs('#contact-to').textContent = 'Renaud BREVIERE-ABRAHAM';
qs('#contact-reason').value = 'Erreur';
qs('#contact-msg').value = '';
// Stocker le contexte du contrat à l'ouverture de la modale contact
window.__contactCtx = {
recordId: rec.id,
reference: f.Reference || rec.id,
matricule: f['Matricule API'] || f.Matricule || null,
templateId: f.docuseal_template_id || null
};
// ouvre la modale
const dlgc = qs('#dlg-contact');
if (dlgc && typeof dlgc.showModal === 'function') dlgc.showModal();
else if (dlgc) dlgc.setAttribute('open','');
}
function bindContactActions(records){
const byId = Object.fromEntries(records.map(r=>[r.id, r]));
// Boutons sur les cartes
qsa('[data-contact]').forEach(el=>{
if (el.__bound) return; el.__bound = true;
el.addEventListener('click', e=>{
e.preventDefault();
const id = el.getAttribute('data-contact');
const rec = byId[id];
if(!rec){ alert('Contrat introuvable.'); return; }
openContactDialog(rec);
});
});
// Soumission du formulaire
const form = qs('#contact-form');
if (form && !form.__bound) {
form.__bound = true;
form.addEventListener('submit', async (e)=>{
e.preventDefault();
const to = (window.__managerEmail || 'paie@odentas.fr');
const reason = qs('#contact-reason').value;
const msg = qs('#contact-msg').value || '';
const ref = qs('#contact-ref').textContent;
const mat = qs('#contact-mat').textContent;
const url = location.href;
// Option webhook (si tu me donnes une URL)
const webhook = (CONFIG && CONFIG.CONTACT && CONFIG.CONTACT.WEBHOOK_URL) ? CONFIG.CONTACT.WEBHOOK_URL : null;
console.log('[Hub] contact webhook URL:', webhook || '(none)');
try{
if (webhook) {
const ctx = window.__contactCtx || {};
const batch = window.__batchCache || {};
const bf = (batch.fields||{});
const body = {
source: 'hub_signature_batch',
event: 'contact_request',
timestamp: new Date().toISOString(),
to,
reason,
message: msg,
page: url,
contract: {
airtable_record_id: ctx.recordId || null,
reference: ctx.reference || ref,
matricule: ctx.matricule || mat,
template_id: ctx.templateId || null
},
batch: {
airtable_record_id: batch.id || null,
batch_id: bf.batch_id || null,
employeur: bf.employeur || bf.Employeur || bf.Client || null,
structure: bf['Nom structure'] || bf.Structure || null
}
};
const headers = { 'Content-Type':'application/json' };
if (CONFIG.CONTACT.SECRET) headers['Authorization'] = `Bearer ${CONFIG.CONTACT.SECRET}`;
const res = await fetch(webhook, { method:'POST', mode:'cors', headers, body: JSON.stringify(body) });
if(!res.ok) throw new Error('Webhook HTTP '+res.status);
alert('Votre demande a été envoyée au gestionnaire.');
} else {
// Fallback: mailto
const subject = encodeURIComponent(`[Contrat ${ref}] ${reason}`);
const body = encodeURIComponent(`Bonjour,\n\nJe souhaite ${reason.toLowerCase()} sur le contrat ${ref} (matricule ${mat}).\n\nDétails :\n${msg}\n\nLien : ${url}`);
window.location.href = `mailto:${encodeURIComponent(to)}?subject=${subject}&body=${body}`;
}
qs('#dlg-contact').close();
}catch(err){
console.error('contact submit error', err);
alert("Échec de l'envoi. Merci de réessayer ou d'envoyer un email à " + to);
}
});
}
}
/* ================================
MAIN
================================= */
// Nouvelle fonction loadAndRender pour charger et afficher les données du batch
async function loadAndRender(batchId){
// 1) Batch + contrats associés
const batch = await getBatchByBatchId(batchId);
if(!batch){ qs('#batch-sub').textContent = 'Batch introuvable'; qs('#cards').innerHTML=''; qs('#empty').style.display='block'; return; }
// expose batch globally for contact webhook
window.__batchCache = batch;
const linked = (batch.fields?.contrats || []).map(x=> (typeof x==='string'? x : x.id));
const contrats = await getContratsForBatch(batchId, linked);
// 2) Pour chaque contrat → récupérer la soumission employeur via template_id (si pas déjà en base)
for(const rec of contrats){
const f = rec.fields || {};
let embed = null;
if (f.embed_src_employeur) {
embed = f.embed_src_employeur;
} else if (f.docuseal_template_id) {
try {
const sub = await getEmployerSubmissionByTemplate(f.docuseal_template_id);
embed = sub?.embed_src || null;
} catch (e) {
console.warn('DocuSeal template->submission', e);
}
}
if (embed) {
console.log('[Hub] Embed URL for', f.Reference || rec.id, '=>', embed);
}
rec.fields._employer_embed = embed || null; // champ virtuel pour rendu
}
// 3) Stats + rendu
const total = contrats.length;
const employerSigned = (r) => String(r.fields?.['Contrat signé par employeur']||'').toLowerCase()==='oui';
const salarieSigned = (r) => String(r.fields?.['Contrat signé']||'').toLowerCase()==='oui';
const done = contrats.filter(employerSigned).length; // signés par employeur
const empTodo = contrats.filter(r => employerSigned(r) && !salarieSigned(r)).length; // en attente salarié (après signature employeur)
const finished = contrats.filter(r => employerSigned(r) && salarieSigned(r)).length; // tous signés
const ready = contrats.filter(r => {
const f = r.fields || {};
return !!f._employer_embed && !employerSigned(r); // à signer par l'employeur
}).length;
renderBatchHeader(batch, total, ready, done, empTodo, finished);
const cardsEl = qs('#cards');
if(!contrats.length){ qs('#empty').style.display='block'; cardsEl.innerHTML=''; window.__contratsCache = contrats; return; }
qs('#empty').style.display='none';
cardsEl.innerHTML = contrats.map(cardTemplate).join('');
bindCardActions(contrats);
bindContactActions(contrats);
// Met en cache pour le filtre
window.__contratsCache = contrats;
// Met en cache pour le filtre
window.__contratsCache = contrats;
}
// Helpers overlay et polling
function showPollOverlay(){
const el = qs('#poll-timeout-overlay');
if (el) el.style.display = 'block';
setSyncStatus('paused');
}
function hidePollOverlay(){
const el = qs('#poll-timeout-overlay');
if (el) el.style.display = 'none';
setSyncStatus('active');
}
function stopPolling(){
if (window.__pollTimer) { clearInterval(window.__pollTimer); window.__pollTimer = null; }
if (window.__pollStopTimer) { clearTimeout(window.__pollStopTimer); window.__pollStopTimer = null; }
window.__pollBusy = false;
}
function armPollStopTimer(){
if (!(CONFIG.POLL_MAX_MS && CONFIG.POLL_MAX_MS > 0)) return;
if (window.__pollStopTimer) clearTimeout(window.__pollStopTimer);
window.__pollStopTimer = setTimeout(()=>{ stopPolling(); showPollOverlay(); }, CONFIG.POLL_MAX_MS);
}
// Auto resume helper: hides overlay, refreshes once, restarts polling
async function autoResume(batchId){
if (!batchId) batchId = window.__batchId;
if (!batchId) return;
hidePollOverlay();
try { await refreshNow(batchId, 'Reconnexion en cours…'); } catch(e){ console.warn(e); }
startPolling(batchId);
}
// Polling starter qui évite les requêtes superposées, armement timer d'arrêt
function startPolling(batchId){
stopPolling(); // nettoie d'abord
window.__pollBusy = false;
window.__pollTimer = setInterval(async () => {
if (window.__pollBusy) return;
window.__pollBusy = true;
try { await loadAndRender(batchId); } catch(e){ console.warn('poll error', e); }
finally { window.__pollBusy = false; }
}, CONFIG.POLL_INTERVAL_MS || 10000);
// ⏳ arme/ réarme le timer darrêt
armPollStopTimer();
setSyncStatus('active');
}
async function main(){
initTheme();
const { batchId } = paramsFromUrl();
window.__batchId = batchId; // expose batchId for overlay click-to-resume
if(!batchId){ alert('Paramètre batchId manquant'); return; }
showPageLoader('Connexion aux serveurs Odentas…');
// Premier rendu
await loadAndRender(batchId);
hidePageLoader();
// Theme toggle binding (checkbox)
const tbtn = qs('#theme-toggle');
if (tbtn && !tbtn.__bound){
tbtn.__bound = true;
tbtn.addEventListener('change', ()=>{
const next = tbtn.checked ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem('hub_theme', next);
});
}
// Overlay actions
const btnReload = qs('#btn-reload');
if (btnReload && !btnReload.__bound){
btnReload.__bound = true;
btnReload.addEventListener('click', ()=> location.reload());
}
const btnResume = qs('#btn-resume-poll');
if (btnResume && !btnResume.__bound){
btnResume.__bound = true;
btnResume.addEventListener('click', async ()=>{
hidePollOverlay();
try { await refreshNow(batchId, 'Reconnexion en cours…'); } catch(e){ console.warn(e); }
startPolling(batchId); // relance + réarmement du stop timer
});
}
// Clique n'importe où sur l'overlay (ou dans son contenu) pour réactiver la synchro
const ov = qs('#poll-timeout-overlay');
if (ov && !ov.__boundClickAnywhere){
ov.__boundClickAnywhere = true;
ov.addEventListener('click', async () => {
hidePollOverlay();
const bid = window.__batchId || batchId;
try { await refreshNow(bid, 'Reconnexion en cours…'); } catch(e){ console.warn(e); }
if (bid) startPolling(bid);
});
}
// Ajoute aussi une reprise au clavier (touche quelconque) tant que loverlay est visible.
const resumeOnAnyKey = (e) => {
const el = qs('#poll-timeout-overlay');
if (!el || el.style.display === 'none') return;
hidePollOverlay();
if (window.__batchId) startPolling(window.__batchId);
};
if (!window.__resumeKeyBound){
window.__resumeKeyBound = true;
window.addEventListener('keydown', resumeOnAnyKey);
}
// 🔄 Réarme sur activité utilisateur (si loverlay nest pas affiché)
const resetOnActivity = () => {
const ov = qs('#poll-timeout-overlay');
if (!ov || ov.style.display === 'none') armPollStopTimer();
};
['click','keydown','mousemove','touchstart','focus'].forEach(evt => {
window.addEventListener(evt, resetOnActivity, { passive: true });
});
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'hidden') {
// Pause as soon as the tab is hidden
stopPolling();
showPollOverlay();
} else {
// Tab becomes visible again -> auto resume
await autoResume(window.__batchId || batchId);
}
});
window.addEventListener('blur', () => {
// If user switches window/app, pause to avoid useless traffic
stopPolling();
showPollOverlay();
});
window.addEventListener('focus', async () => {
// Coming back to the window -> auto resume
await autoResume(window.__batchId || batchId);
});
// Démarre le polling périodique
startPolling(batchId);
}
main().catch(err=>{
console.error(err);
hidePageLoader();
alert('Erreur: '+err.message);
});
</script>
</body>
</html>