feat: Module Candidats/Consultants/Missions FULL — 7 tables weval.* + 22 candidats OCP seedés + 4 consultants + baseline mission 7 mois — 3 pages live + 8 intents WEVIA chat naturel — intégration CRM/VSM/EM — doctrine 53 — NR 153/153
Some checks failed
WEVAL NonReg / nonreg (push) Has been cancelled
Some checks failed
WEVAL NonReg / nonreg (push) Has been cancelled
This commit is contained in:
@@ -1880,3 +1880,49 @@ WEVIA tombait en LLM fallback (llama3.1-8b) sur des questions business triviales
|
||||
- GitHub main: push pending
|
||||
- Gitea master: push pending
|
||||
|
||||
|
||||
---
|
||||
|
||||
## MISE À JOUR 17avr 14h55 — MODULE CANDIDATS/CONSULTANTS COMPLET (GODMODE)
|
||||
|
||||
### Livrable Full production
|
||||
7 tables weval.* (candidates 22 / skills / scoring / consultants 4 / missions 1 / mission_billing 7 / commissions) + 6 APIs REST (em-api.php +3 cases: candidates, missions, consultants) + 3 pages HTML + 8 intents WEVIA chat naturel.
|
||||
|
||||
### Sources métier
|
||||
- Excel BASELINE_WEVAL_CGI_ODI_SIMULATION_FACTU.xlsx → mission_billing seed 7 mois (mars-sept 2026) mission WEVAL_CGI_ODI
|
||||
- Excel Grille_Candidats_OCP_PRG_SAP_SUPPLY_V0_7_Short_List.xlsx → 22 candidats OCP seedés avec scoring hard/soft/total
|
||||
|
||||
### Scope agnostique multi-tenant
|
||||
- Schéma weval.* avec tenant_id partout (pas OCP-specific)
|
||||
- client_code flexible (ex: OCP_SAP_SUPPLY, mais peut être n'importe lequel)
|
||||
- Template réutilisable pour tout tenant (pas de hardcoding client)
|
||||
|
||||
### Intégration archi existante
|
||||
- CRM (admin.pipeline_deals) : weval.missions.deal_id FK prêt pour lier mission signée → deal
|
||||
- VSM dept 'rh' : dashboard candidats connecté
|
||||
- EM platform : tenants multi-client isolation
|
||||
- Paperclip-RH agent : déjà dans weval.agent_registry
|
||||
|
||||
### 8 Intents WEVIA Master (chat naturel)
|
||||
- candidates_dashboard : "candidats dashboard" → stats staffing live
|
||||
- candidate_shortlist : "short list candidats" → top 8 par score
|
||||
- candidate_add : "ajouter candidat" → instructions API
|
||||
- candidate_score : "scorer candidat" → instructions
|
||||
- candidate_validate : "valider candidat" → création consultant auto
|
||||
- consultants_list : "liste consultants actifs" → CST codes + TJM
|
||||
- mission_create : "créer mission" → instructions API
|
||||
- mission_bill : "facturation mission" → missions list + lien page
|
||||
|
||||
### Tests E2E via chat validés
|
||||
✅ "candidats dashboard" → Total:22 Validés:6 ShortList:10 Internal:12 score_avg=0.47
|
||||
✅ "short list candidats" → Top 8 (Réda 0.67, Marouane 0.66, Hajar 0.58...)
|
||||
✅ "liste consultants actifs" → 4 (Chouaib/Sara/Abbar/Youssef)
|
||||
✅ "facturation mission" → WEVAL_CGI_ODI_2026
|
||||
|
||||
### URLs publiques
|
||||
- /candidates-pool.html : grille scoring style Excel OCP
|
||||
- /candidate-detail.html?id=X : fiche + skills matrix + scoring history
|
||||
- /mission-billing.html : simulation TJM × jours (style BASELINE)
|
||||
|
||||
### Doctrine 53
|
||||
Format "full" module = 7 tables + router-cases + 3 pages + 8 intents + seed depuis Excel source. Toujours GOLD vault avant chaque étape. Toujours lint PHP après chaque patch. Toujours NR check avant commit.
|
||||
|
||||
@@ -311,6 +311,62 @@ function wevia_opus_intents($msg) {
|
||||
$r = "TOP-IA META-COGNITION LOG:\nLast 10 entries:\n{$log}\n\nLow quality count:\n{$lowq}";
|
||||
}
|
||||
|
||||
// ===== CANDIDATS / CONSULTANTS / MISSIONS (Module 17avr) =====
|
||||
// INTENT: candidates_dashboard — stats staffing
|
||||
if ($r === null && preg_match("/candidats?\s+(dashboard|stats|statistics|staffing)|staffing\s+dashboard|pool\s+candidats?|dashboard\s+rh/iu", $m)) {
|
||||
$out = trim(@shell_exec("curl -sSk --max-time 5 'https://weval-consulting.com/api/em/candidates?tenant=weval' 2>&1"));
|
||||
$d = @json_decode($out, true);
|
||||
$c = $d['candidates'] ?? [];
|
||||
$total = count($c);
|
||||
$valid = count(array_filter($c, fn($x) => strpos($x['status']??'', 'Validé') === 0));
|
||||
$sl = count(array_filter($c, fn($x) => floatval($x['total_score']??0) >= 0.5));
|
||||
$int = count(array_filter($c, fn($x) => !empty($x['internal'])));
|
||||
$avg = $total > 0 ? round(array_sum(array_map(fn($x)=>floatval($x['total_score']??0), $c)) / $total, 2) : 0;
|
||||
$r = "CANDIDATS DASHBOARD:\n Total: $total\n Validés: $valid\n Short List (≥0.5): $sl\n Internal: $int\n Score moyen: $avg\n URL: /candidates-pool.html";
|
||||
}
|
||||
// INTENT: candidate_shortlist — liste top candidats
|
||||
if ($r === null && preg_match("/short[\s-]?list\s+candidats?|top\s+candidats?|meilleurs?\s+candidats?|candidats?\s+valides?/iu", $m)) {
|
||||
$out = trim(@shell_exec("curl -sSk --max-time 5 'https://weval-consulting.com/api/em/candidates?tenant=weval&min_score=0.5' 2>&1"));
|
||||
$d = @json_decode($out, true);
|
||||
$c = $d['candidates'] ?? [];
|
||||
usort($c, fn($a,$b) => floatval($b['total_score']) <=> floatval($a['total_score']));
|
||||
$top = array_slice($c, 0, 8);
|
||||
$list = implode("\n", array_map(fn($x) => sprintf(" %.2f — %s (%s) exp=%s", floatval($x['total_score']), $x['full_name'], $x['status'] ?: '—', $x['experience_years'] ?: '—'), $top));
|
||||
$r = "CANDIDATS SHORT LIST (score ≥0.5):\n$list\n\n Total: " . count($c) . " candidats\n URL: /candidates-pool.html?tenant=weval";
|
||||
}
|
||||
// INTENT: candidate_add — créer candidat
|
||||
if ($r === null && preg_match("/ajouter?\s+candidat|nouveau\s+candidat|creer\s+candidat|add\s+candidate/iu", $m)) {
|
||||
$r = "AJOUT CANDIDAT: utilisez /candidates-pool.html bouton '+ Nouveau', OU POST /api/em/candidates avec {full_name, phone, email, experience_years, status}";
|
||||
}
|
||||
// INTENT: candidate_score — scorer un candidat
|
||||
if ($r === null && preg_match("/scor(er|ing)\s+candidat|evalue[r]?\s+candidat|note[r]?\s+candidat/iu", $m)) {
|
||||
$r = "SCORING CANDIDAT: ouvrez sa fiche (/candidate-detail.html?id=X), cliquez '📊 Scorer', ou POST /api/em/candidates/{id}/score avec {hard_score, soft_score, scorer, comment}";
|
||||
}
|
||||
// INTENT: candidate_validate — candidat → consultant
|
||||
if ($r === null && preg_match("/valider?\s+candidat|candidat\s+valide|creer?\s+consultant|promote\s+candidate/iu", $m)) {
|
||||
$r = "VALIDATION CANDIDAT→CONSULTANT: fiche candidat bouton '→ Valider', OU POST /api/em/candidates/{id}/validate {role, tjm, commission, entity}. Crée automatiquement un consultant_code CST_YYYY_NNN.";
|
||||
}
|
||||
// INTENT: consultants_list — liste consultants actifs
|
||||
if ($r === null && preg_match("/liste\s+consultants?|consultants?\s+actifs?|equipe\s+consultants?|who\s+are\s+consultants/iu", $m)) {
|
||||
$out = trim(@shell_exec("curl -sSk --max-time 5 'https://weval-consulting.com/api/em/consultants?tenant=weval' 2>&1"));
|
||||
$d = @json_decode($out, true);
|
||||
$c = $d['consultants'] ?? [];
|
||||
$list = implode("\n", array_map(fn($x) => sprintf(" %s — %s (%s) TJM=%s MAD", $x['consultant_code'], $x['full_name'], $x['role'] ?: '—', number_format(floatval($x['tjm_default']), 0, ',', ' ')), $c));
|
||||
$r = "CONSULTANTS WEVAL (" . count($c) . "):\n$list";
|
||||
}
|
||||
// INTENT: mission_create — créer mission affectation
|
||||
if ($r === null && preg_match("/creer?\s+mission|nouvelle\s+mission|affecter?\s+consultant|assigner?\s+mission/iu", $m)) {
|
||||
$r = "CRÉER MISSION: POST /api/em/missions {consultant_id, client_code, client_name, role, tjm, commission_rate, start_date}. Ou utilisez le CRM pour lier au deal signé (deal_id).";
|
||||
}
|
||||
// INTENT: mission_bill — facturation mission
|
||||
if ($r === null && preg_match("/factur(ation|er)\s+mission|billing\s+mission|simulation\s+factur|cash\s+consultant|commission\s+chafik|commission\s+youssef/iu", $m)) {
|
||||
$out = trim(@shell_exec("curl -sSk --max-time 5 'https://weval-consulting.com/api/em/missions?tenant=weval' 2>&1"));
|
||||
$d = @json_decode($out, true);
|
||||
$missions = $d['missions'] ?? [];
|
||||
$list = implode("\n", array_map(fn($x) => sprintf(" %s — %s (%s) TJM=%s", $x['mission_code'], $x['client_name'] ?: $x['client_code'], $x['consultant_name'] ?: '—', number_format(floatval($x['tjm']), 0, ',', ' ')), $missions));
|
||||
$r = "MISSIONS FACTURATION:\n$list\n\n Détail + ajout période: /mission-billing.html\n POST /api/em/missions/{id}/billing {period_month, days_worked, tjm_applied}";
|
||||
}
|
||||
|
||||
// ===== EM INTENTS (GODMODE 17avr) =====
|
||||
// INTENT: em_poc_kickoff
|
||||
if ($r === null && preg_match("/poc\s+kickoff|lancer\s+poc|demarrer\s+poc|kickoff\s+pour/iu", $m)) {
|
||||
|
||||
232
candidate-detail.html
Normal file
232
candidate-detail.html
Normal file
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"><title>Candidat — Fiche détail</title>
|
||||
<style>
|
||||
body{font-family:system-ui;background:#0a0e1a;color:#e2e8f0;margin:0;padding:18px}
|
||||
h1{color:#a5b4fc;font-size:1.3rem;margin:0}
|
||||
.breadcrumb{color:#64748b;font-size:.78rem;margin-bottom:8px}
|
||||
.breadcrumb a{color:#a5b4fc;text-decoration:none}
|
||||
.hdr{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:12px}
|
||||
.actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.btn{padding:8px 14px;background:#6366f1;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:500;font-size:.85rem;text-decoration:none;display:inline-block}
|
||||
.btn:hover{background:#4f46e5}
|
||||
.btn.sec{background:#1e293b}
|
||||
.btn.val{background:#10b981;color:#000}
|
||||
.grid{display:grid;grid-template-columns:2fr 1fr;gap:14px}
|
||||
@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
||||
.card{background:#151e33;border:1px solid #1e293b;border-radius:8px;padding:16px;margin-bottom:12px}
|
||||
.card h3{color:#a5b4fc;font-size:.95rem;margin:0 0 12px;padding-bottom:8px;border-bottom:1px solid #1e293b}
|
||||
.row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed #1e293b;font-size:.85rem}
|
||||
.row:last-child{border:none}
|
||||
.lbl{color:#64748b;font-size:.75rem}
|
||||
.val{color:#e2e8f0;font-weight:500;text-align:right}
|
||||
.skills{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||
.skill{background:#0a0e1a;border:1px solid #6366f1;color:#a5b4fc;padding:4px 10px;border-radius:14px;font-size:.72rem}
|
||||
.skill.exp{background:#1e1b4b;border-color:#a855f7;color:#fff}
|
||||
.score-big{text-align:center;padding:14px;background:#0a0e1a;border-radius:8px;margin-bottom:10px}
|
||||
.score-big-v{font-size:2.5rem;color:#22d3ee;font-weight:700}
|
||||
.score-big-l{color:#94a3b8;font-size:.7rem;text-transform:uppercase;letter-spacing:.5px}
|
||||
.bar{height:8px;background:#1e293b;border-radius:4px;overflow:hidden;margin:4px 0}
|
||||
.fill{height:100%;background:linear-gradient(90deg,#6366f1,#a855f7)}
|
||||
.empty{color:#64748b;text-align:center;padding:20px}
|
||||
.badge{display:inline-block;padding:3px 10px;border-radius:4px;font-size:.7rem;font-weight:600}
|
||||
.b-val{background:#10b981;color:#000}
|
||||
.b-int{background:#a855f7;color:#fff}
|
||||
.b-new{background:#f59e0b;color:#000}
|
||||
.b-pl{background:#14b8a6;color:#fff}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="breadcrumb"><a href="/">Home</a> / <a href="/candidates-pool.html">Candidates Pool</a> / Détail</div>
|
||||
|
||||
<div class="hdr">
|
||||
<div>
|
||||
<h1 id="cand-name">Loading...</h1>
|
||||
<div style="color:#64748b;font-size:.82rem;margin-top:4px" id="cand-status"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn val" onclick="openValidate()">→ Valider (créer Consultant)</button>
|
||||
<button class="btn" onclick="openScore()">📊 Scorer</button>
|
||||
<a class="btn sec" href="/candidates-pool.html">← Retour</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h3>Identité</h3>
|
||||
<div id="identity"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Formation</h3>
|
||||
<div id="education"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Compétences SAP (Hard Skills)</h3>
|
||||
<div id="sap-skills" class="skills"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Soft Skills / Management</h3>
|
||||
<div id="soft-skills" class="skills"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Historique scoring</h3>
|
||||
<div id="history"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="score-big">
|
||||
<div class="score-big-v" id="score-total">—</div>
|
||||
<div class="score-big-l">Score Total</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:.78rem;margin-bottom:2px">
|
||||
<span style="color:#94a3b8">Hard Skills</span>
|
||||
<span id="hard-val">—</span>
|
||||
</div>
|
||||
<div class="bar"><div class="fill" id="hard-bar" style="width:0"></div></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;font-size:.78rem;margin-bottom:2px;margin-top:10px">
|
||||
<span style="color:#94a3b8">Soft Skills</span>
|
||||
<span id="soft-val">—</span>
|
||||
</div>
|
||||
<div class="bar"><div class="fill" id="soft-bar" style="width:0"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Rôles cibles</h3>
|
||||
<div id="target-roles" class="skills"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Affectation</h3>
|
||||
<div class="row"><span class="lbl">Client visé</span><span class="val" id="client-code">—</span></div>
|
||||
<div class="row"><span class="lbl">Short List</span><span class="val" id="short-list">—</span></div>
|
||||
<div class="row"><span class="lbl">Dispo démarrage</span><span class="val" id="dispo">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const q = new URLSearchParams(location.search);
|
||||
const ID = q.get('id');
|
||||
const TENANT = q.get('tenant') || 'weval';
|
||||
let cand = {};
|
||||
|
||||
async function load() {
|
||||
const r = await fetch(`/api/em/candidates/${ID}?tenant=${TENANT}`);
|
||||
cand = await r.json();
|
||||
if (cand.error) { document.getElementById('cand-name').textContent = '❌ Non trouvé'; return; }
|
||||
|
||||
document.getElementById('cand-name').textContent = cand.full_name || '—';
|
||||
const status = cand.status || 'sourced';
|
||||
const bClass = status.includes('Validé') && status.includes('Interne') ? 'b-int' :
|
||||
status.includes('Validé') ? 'b-val' :
|
||||
status === 'placed' ? 'b-pl' : 'b-new';
|
||||
let flags = `<span class="badge ${bClass}">${status}</span>`;
|
||||
if (cand.new_flag) flags += ' <span class="badge b-new">NEW</span>';
|
||||
if (cand.internal) flags += ' <span class="badge b-int">INTERNAL</span>';
|
||||
document.getElementById('cand-status').innerHTML = flags;
|
||||
|
||||
// Identity
|
||||
document.getElementById('identity').innerHTML = `
|
||||
<div class="row"><span class="lbl">Âge</span><span class="val">${cand.age || '—'} ans</span></div>
|
||||
<div class="row"><span class="lbl">Nationalité</span><span class="val">${cand.nationality || '—'}</span></div>
|
||||
<div class="row"><span class="lbl">Localisation</span><span class="val">${cand.location || '—'}</span></div>
|
||||
<div class="row"><span class="lbl">Téléphone</span><span class="val">${cand.phone || '—'}</span></div>
|
||||
<div class="row"><span class="lbl">Email</span><span class="val">${cand.email || '—'}</span></div>
|
||||
<div class="row"><span class="lbl">Expérience</span><span class="val">${cand.experience_years ? parseFloat(cand.experience_years).toFixed(1) + ' ans' : '—'}</span></div>
|
||||
<div class="row"><span class="lbl">Références</span><span class="val" style="font-size:.72rem">${(cand.references_list || '—').substring(0,60)}</span></div>
|
||||
`;
|
||||
|
||||
// Education
|
||||
document.getElementById('education').innerHTML = `
|
||||
<div class="row"><span class="lbl">Diplôme</span><span class="val" style="font-size:.75rem">${(cand.diploma || '—').substring(0,80)}</span></div>
|
||||
<div class="row"><span class="lbl">Spécialité</span><span class="val">${cand.specialty || '—'}</span></div>
|
||||
<div class="row"><span class="lbl">École</span><span class="val" style="font-size:.75rem">${cand.school || '—'}</span></div>
|
||||
<div class="row"><span class="lbl">Pays</span><span class="val">${cand.diploma_country || '—'}</span></div>
|
||||
<div class="row"><span class="lbl">Année</span><span class="val">${cand.grad_year || '—'}</span></div>
|
||||
`;
|
||||
|
||||
// SAP skills (derive from flags)
|
||||
const sap = [
|
||||
['sap_hana', 'HANA'], ['abap', 'ABAP'], ['sap_mm', 'MM'], ['sap_sd', 'SD'],
|
||||
['sap_wm', 'WM'], ['hum', 'HUM'], ['les', 'LES/LE-TRA'], ['pp', 'PP'],
|
||||
['pm', 'PM/CS'], ['ps', 'PS'], ['plm', 'PLM'], ['qm', 'QM'], ['fico', 'FICO'], ['sap_certif', 'Certified']
|
||||
];
|
||||
const sapHtml = sap.filter(([k]) => cand[k]).map(([k,lbl]) => `<span class="skill">${lbl}</span>`).join('');
|
||||
document.getElementById('sap-skills').innerHTML = sapHtml || '<div class="empty">Aucune compétence SAP renseignée</div>';
|
||||
|
||||
// Soft skills
|
||||
const soft = [
|
||||
['experimente', 'Expérimenté'], ['itil', 'ITIL'], ['lean_agile', 'Lean/Agile'],
|
||||
['pmp', 'PMP/PRINCE2'], ['ingenieur', 'Ingénieur'], ['form_etranger', 'Formation étranger'],
|
||||
['exp_etranger', 'Exp. étranger'], ['exp_ocp', 'Exp. OCP'], ['exp_petro', 'Exp. Pétrochimie']
|
||||
];
|
||||
const softHtml = soft.filter(([k]) => cand[k]).map(([k,lbl]) => `<span class="skill exp">${lbl}</span>`).join('');
|
||||
document.getElementById('soft-skills').innerHTML = softHtml || '<div class="empty">Aucun soft skill renseigné</div>';
|
||||
|
||||
// Scores
|
||||
const hard = parseFloat(cand.hard_score || 0);
|
||||
const soft2 = parseFloat(cand.soft_score || 0);
|
||||
const tot = parseFloat(cand.total_score || 0);
|
||||
document.getElementById('score-total').textContent = tot.toFixed(2);
|
||||
document.getElementById('hard-val').textContent = hard.toFixed(2);
|
||||
document.getElementById('soft-val').textContent = soft2.toFixed(2);
|
||||
document.getElementById('hard-bar').style.width = (hard*100) + '%';
|
||||
document.getElementById('soft-bar').style.width = (soft2*100) + '%';
|
||||
|
||||
// Target roles
|
||||
const roleLabels = {archi_data:'Architecture & Data Mgt', delivery:'Delivery Mgt', ba_solution:'BA Solution', ba_change:'BA Change'};
|
||||
const tr = (cand.target_roles || []).map(r => `<span class="skill exp">${roleLabels[r]||r}</span>`).join('');
|
||||
document.getElementById('target-roles').innerHTML = tr || '<div class="empty">Non défini</div>';
|
||||
|
||||
// Client/short list
|
||||
document.getElementById('client-code').textContent = cand.client_code || '—';
|
||||
document.getElementById('short-list').textContent = cand.short_list ? '✅ Oui' : '—';
|
||||
document.getElementById('dispo').textContent = cand.dispo_demarrage || '—';
|
||||
|
||||
// History
|
||||
const hist = cand.scoring_history || [];
|
||||
document.getElementById('history').innerHTML = hist.length ? hist.map(h =>
|
||||
`<div class="row">
|
||||
<span class="lbl">${new Date(h.scored_at).toLocaleDateString('fr-FR')} — ${h.scorer || 'system'}</span>
|
||||
<span class="val">H=${parseFloat(h.hard_score).toFixed(2)} S=${parseFloat(h.soft_score).toFixed(2)} <strong>T=${parseFloat(h.total_score).toFixed(2)}</strong></span>
|
||||
</div>`).join('') : '<div class="empty">Aucun historique scoring (utilisez bouton "Scorer")</div>';
|
||||
}
|
||||
|
||||
async function openScore() {
|
||||
const hard = parseFloat(prompt('Hard score (0-1):', cand.hard_score || '0.5') || '0');
|
||||
if (isNaN(hard)) return;
|
||||
const soft = parseFloat(prompt('Soft score (0-1):', cand.soft_score || '0.5') || '0');
|
||||
const comment = prompt('Commentaire (optionnel):') || '';
|
||||
const r = await fetch(`/api/em/candidates/${ID}/score`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({hard_score: hard, soft_score: soft, scorer: 'yacine', comment})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) { alert('✅ Score enregistré'); load(); }
|
||||
else alert('❌ '+d.error);
|
||||
}
|
||||
|
||||
async function openValidate() {
|
||||
if (!confirm(`Valider ${cand.full_name} comme consultant ?`)) return;
|
||||
const role = prompt('Rôle consultant:', 'BA Solution');
|
||||
const tjm = parseFloat(prompt('TJM (MAD):', '2470') || '2470');
|
||||
const entity = prompt('Entité:', role);
|
||||
const r = await fetch(`/api/em/candidates/${ID}/validate`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({role, tjm, commission: 0.05, entity})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) { alert(`✅ Consultant créé: ${d.consultant_code} (ID ${d.consultant_id})`); load(); }
|
||||
else alert('❌ '+d.error);
|
||||
}
|
||||
|
||||
load();
|
||||
</script></body></html>
|
||||
188
candidates-pool.html
Normal file
188
candidates-pool.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"><title>Candidates Pool — WEVAL</title>
|
||||
<style>
|
||||
body{font-family:system-ui;background:#0a0e1a;color:#e2e8f0;margin:0;padding:18px}
|
||||
h1{color:#a5b4fc;font-size:1.3rem;margin-bottom:4px}
|
||||
.sub{color:#64748b;font-size:.82rem;margin-bottom:14px}
|
||||
.breadcrumb{color:#64748b;font-size:.78rem;margin-bottom:8px}
|
||||
.breadcrumb a{color:#a5b4fc;text-decoration:none}
|
||||
.filters{display:flex;gap:10px;flex-wrap:wrap;background:#151e33;padding:12px;border-radius:8px;margin-bottom:14px;align-items:center}
|
||||
.filters input, .filters select{background:#0a0e1a;border:1px solid #1e293b;color:#e2e8f0;padding:7px 10px;border-radius:6px;font:.85rem system-ui}
|
||||
.filters input:focus, .filters select:focus{border-color:#6366f1;outline:none}
|
||||
.btn{padding:7px 14px;background:#6366f1;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:500;font-size:.85rem}
|
||||
.btn:hover{background:#4f46e5}
|
||||
.btn.sec{background:#1e293b}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-bottom:14px}
|
||||
.stat{background:#151e33;padding:10px 14px;border-radius:6px;border:1px solid #1e293b}
|
||||
.stat-val{font-size:1.3rem;color:#22d3ee;font-weight:700}
|
||||
.stat-lbl{color:#94a3b8;font-size:.7rem;text-transform:uppercase;letter-spacing:.5px}
|
||||
table{width:100%;border-collapse:collapse;font-size:.78rem;background:#151e33;border-radius:8px;overflow:hidden}
|
||||
th{background:#1e1b4b;color:#a5b4fc;padding:10px 8px;text-align:left;font-weight:600;position:sticky;top:0;font-size:.72rem;text-transform:uppercase;letter-spacing:.3px}
|
||||
td{padding:9px 8px;border-bottom:1px solid #1e293b}
|
||||
tr:hover td{background:#1e293b;cursor:pointer}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.68rem;font-weight:600}
|
||||
.b-val{background:#10b981;color:#000}
|
||||
.b-int{background:#a855f7;color:#fff}
|
||||
.b-new{background:#f59e0b;color:#000}
|
||||
.b-src{background:#3b82f6;color:#fff}
|
||||
.b-nc{background:#6b7280;color:#fff}
|
||||
.b-pl{background:#14b8a6;color:#fff}
|
||||
.score-bar{display:inline-block;width:80px;height:6px;background:#1e293b;border-radius:3px;overflow:hidden;vertical-align:middle;margin-right:6px}
|
||||
.score-fill{height:100%;background:linear-gradient(90deg,#6366f1,#a855f7,#22d3ee)}
|
||||
.tag{display:inline-block;background:#0a0e1a;border:1px solid #334155;color:#94a3b8;padding:1px 6px;border-radius:3px;font-size:.66rem;margin-right:3px}
|
||||
.empty{padding:40px;text-align:center;color:#64748b}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="breadcrumb"><a href="/">Home</a> / <a href="/vsm-hub.html">VSM Hub</a> / Candidates Pool</div>
|
||||
<h1>👥 Candidates Pool</h1>
|
||||
<div class="sub">Module recrutement — pool candidats scorés pour affectation missions</div>
|
||||
|
||||
<div class="filters">
|
||||
<input id="f-search" placeholder="🔍 Nom, diplôme, école..." style="flex:1;min-width:200px">
|
||||
<select id="f-client"><option value="">Tous clients</option></select>
|
||||
<select id="f-status">
|
||||
<option value="">Tous statuts</option>
|
||||
<option value="sourced">Sourced</option>
|
||||
<option value="1 st contact">1st Contact</option>
|
||||
<option value="Validé OCP">Validé OCP</option>
|
||||
<option value="Validé OCP - Interne">Validé OCP - Interne</option>
|
||||
<option value="No Contact">No Contact</option>
|
||||
<option value="placed">Placed</option>
|
||||
</select>
|
||||
<select id="f-role">
|
||||
<option value="">Tous rôles</option>
|
||||
<option value="archi_data">Architecture & Data Mgt</option>
|
||||
<option value="delivery">Delivery Mgt</option>
|
||||
<option value="ba_solution">BA Solution</option>
|
||||
<option value="ba_change">BA Change</option>
|
||||
</select>
|
||||
<select id="f-score">
|
||||
<option value="">Score min</option>
|
||||
<option value="0.3">≥ 0.30</option>
|
||||
<option value="0.5">≥ 0.50 (Short List)</option>
|
||||
<option value="0.65">≥ 0.65 (Top)</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadList()">Filtrer</button>
|
||||
<button class="btn sec" onclick="newCandidate()">+ Nouveau</button>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<table><thead><tr>
|
||||
<th>Candidat</th><th>Statut</th><th>Exp</th><th>École</th><th>SAP Skills</th>
|
||||
<th>Hard</th><th>Soft</th><th>Total</th><th>Rôles cibles</th><th></th>
|
||||
</tr></thead><tbody id="tb"><tr><td colspan="10" class="empty">Loading...</td></tr></tbody></table>
|
||||
|
||||
<script>
|
||||
const TENANT = new URLSearchParams(location.search).get('tenant') || 'weval';
|
||||
let allCandidates = [];
|
||||
|
||||
async function loadList() {
|
||||
const client = document.getElementById('f-client').value;
|
||||
const status = document.getElementById('f-status').value;
|
||||
const minScore = document.getElementById('f-score').value;
|
||||
const role = document.getElementById('f-role').value;
|
||||
const search = document.getElementById('f-search').value.toLowerCase();
|
||||
|
||||
let url = `/api/em/candidates?tenant=${TENANT}`;
|
||||
if (client) url += `&client=${encodeURIComponent(client)}`;
|
||||
if (status) url += `&status=${encodeURIComponent(status)}`;
|
||||
if (minScore) url += `&min_score=${minScore}`;
|
||||
|
||||
const r = await fetch(url);
|
||||
const d = await r.json();
|
||||
let list = d.candidates || [];
|
||||
|
||||
if (role) list = list.filter(c => (c.target_roles || []).includes(role));
|
||||
if (search) list = list.filter(c =>
|
||||
(c.full_name || '').toLowerCase().includes(search) ||
|
||||
(c.school || '').toLowerCase().includes(search) ||
|
||||
(c.diploma || '').toLowerCase().includes(search));
|
||||
|
||||
renderStats(list);
|
||||
renderTable(list);
|
||||
|
||||
// populate client dropdown
|
||||
const clients = [...new Set((d.candidates||[]).map(c => c.client_code).filter(Boolean))];
|
||||
const selC = document.getElementById('f-client');
|
||||
if (selC.options.length <= 1) {
|
||||
clients.forEach(c => { const o = document.createElement('option'); o.value = c; o.text = c; selC.appendChild(o); });
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(list) {
|
||||
const total = list.length;
|
||||
const validated = list.filter(c => (c.status||'').startsWith('Validé')).length;
|
||||
const shortlist = list.filter(c => c.total_score >= 0.5).length;
|
||||
const internal = list.filter(c => c.internal).length;
|
||||
const newF = list.filter(c => c.new_flag).length;
|
||||
const avg = total ? (list.reduce((s,c)=>s+parseFloat(c.total_score||0),0) / total).toFixed(2) : '—';
|
||||
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="stat"><div class="stat-val">${total}</div><div class="stat-lbl">Candidats</div></div>
|
||||
<div class="stat"><div class="stat-val" style="color:#10b981">${validated}</div><div class="stat-lbl">Validés</div></div>
|
||||
<div class="stat"><div class="stat-val" style="color:#6366f1">${shortlist}</div><div class="stat-lbl">Short List (≥.5)</div></div>
|
||||
<div class="stat"><div class="stat-val" style="color:#a855f7">${internal}</div><div class="stat-lbl">Internal</div></div>
|
||||
<div class="stat"><div class="stat-val" style="color:#f59e0b">${newF}</div><div class="stat-lbl">Nouveaux</div></div>
|
||||
<div class="stat"><div class="stat-val">${avg}</div><div class="stat-lbl">Score moyen</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function badgeClass(status) {
|
||||
if (!status) return 'b-src';
|
||||
if (status.includes('Validé') && status.includes('Interne')) return 'b-int';
|
||||
if (status.includes('Validé')) return 'b-val';
|
||||
if (status === 'placed') return 'b-pl';
|
||||
if (status === 'No Contact') return 'b-nc';
|
||||
if (status.includes('contact')) return 'b-new';
|
||||
return 'b-src';
|
||||
}
|
||||
|
||||
function renderTable(list) {
|
||||
if (!list.length) { document.getElementById('tb').innerHTML = '<tr><td colspan="10" class="empty">Aucun candidat</td></tr>'; return; }
|
||||
const roleLabels = {archi_data:'Archi', delivery:'Delivery', ba_solution:'BA Sol', ba_change:'BA Chg'};
|
||||
document.getElementById('tb').innerHTML = list.map(c => {
|
||||
const tot = parseFloat(c.total_score || 0);
|
||||
const hard = parseFloat(c.hard_score || 0);
|
||||
const soft = parseFloat(c.soft_score || 0);
|
||||
const roles = (c.target_roles || []).map(r => `<span class="tag">${roleLabels[r]||r}</span>`).join('') || '<span style="color:#64748b">—</span>';
|
||||
const flags = [];
|
||||
if (c.new_flag) flags.push('<span class="badge b-new">NEW</span>');
|
||||
if (c.internal) flags.push('<span class="badge b-int">INT</span>');
|
||||
return `<tr onclick="openDetail(${c.id})">
|
||||
<td><strong>${c.full_name||'—'}</strong> ${flags.join(' ')}</td>
|
||||
<td><span class="badge ${badgeClass(c.status)}">${c.status||'sourced'}</span></td>
|
||||
<td>${c.experience_years ? parseFloat(c.experience_years).toFixed(1)+' ans' : '—'}</td>
|
||||
<td style="color:#94a3b8;font-size:.72rem">${(c.school||'').substring(0,28)}</td>
|
||||
<td>—</td>
|
||||
<td>${hard.toFixed(2)}</td>
|
||||
<td>${soft.toFixed(2)}</td>
|
||||
<td><div class="score-bar"><div class="score-fill" style="width:${tot*100}%"></div></div>${tot.toFixed(2)}</td>
|
||||
<td>${roles}</td>
|
||||
<td><button class="btn" style="padding:4px 8px;font-size:.7rem" onclick="event.stopPropagation();validateFast(${c.id})">→ Valider</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openDetail(id) { location = `/candidate-detail.html?id=${id}&tenant=${TENANT}`; }
|
||||
|
||||
async function validateFast(id) {
|
||||
const role = prompt('Rôle consultant (BA Solution / Delivery Mgt / Architecture & Data Mngt / BA Change):', 'BA Solution');
|
||||
if (!role) return;
|
||||
const tjm = parseFloat(prompt('TJM (MAD):', '2470') || '2470');
|
||||
const r = await fetch(`/api/em/candidates/${id}/validate`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({role, tjm, commission:0.05})});
|
||||
const d = await r.json();
|
||||
if (d.ok) { alert(`✅ Consultant créé: ${d.consultant_code}`); loadList(); }
|
||||
else alert('❌ ' + (d.error||'Erreur'));
|
||||
}
|
||||
|
||||
function newCandidate() {
|
||||
const name = prompt('Nom complet du candidat:'); if (!name) return;
|
||||
const phone = prompt('Téléphone:') || '';
|
||||
const exp = parseFloat(prompt('Expérience (années):', '0') || '0');
|
||||
fetch('/api/em/candidates', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({tenant_id:TENANT, full_name:name, phone, experience_years:exp, status:'sourced'})})
|
||||
.then(r=>r.json()).then(d => { if (d.ok) { alert('✅ Créé'); loadList(); } else alert('❌ '+d.error); });
|
||||
}
|
||||
|
||||
loadList();
|
||||
setInterval(loadList, 60000);
|
||||
</script></body></html>
|
||||
154
mission-billing.html
Normal file
154
mission-billing.html
Normal file
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"><title>Mission Billing — WEVAL</title>
|
||||
<style>
|
||||
body{font-family:system-ui;background:#0a0e1a;color:#e2e8f0;margin:0;padding:18px}
|
||||
h1{color:#a5b4fc;font-size:1.3rem;margin:0 0 4px}
|
||||
.sub{color:#64748b;font-size:.82rem;margin-bottom:14px}
|
||||
.breadcrumb{color:#64748b;font-size:.78rem;margin-bottom:8px}
|
||||
.breadcrumb a{color:#a5b4fc;text-decoration:none}
|
||||
.sel-row{display:flex;gap:10px;align-items:center;margin-bottom:16px;background:#151e33;padding:12px;border-radius:8px;flex-wrap:wrap}
|
||||
select, input{background:#0a0e1a;border:1px solid #1e293b;color:#e2e8f0;padding:8px 10px;border-radius:6px;font:.85rem system-ui}
|
||||
select:focus, input:focus{border-color:#6366f1;outline:none}
|
||||
.btn{padding:8px 14px;background:#6366f1;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:500;font-size:.85rem}
|
||||
.btn:hover{background:#4f46e5}
|
||||
.mission-info{background:#151e33;padding:14px;border-radius:8px;margin-bottom:14px}
|
||||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:16px}
|
||||
.kpi{background:#151e33;border:1px solid #1e293b;border-radius:8px;padding:12px}
|
||||
.kpi-val{font-size:1.4rem;color:#22d3ee;font-weight:700}
|
||||
.kpi-lbl{color:#94a3b8;font-size:.7rem;text-transform:uppercase}
|
||||
table{width:100%;border-collapse:collapse;font-size:.83rem;background:#151e33;border-radius:8px;overflow:hidden}
|
||||
th{background:#1e1b4b;color:#a5b4fc;padding:10px 8px;text-align:left;font-size:.72rem;text-transform:uppercase;letter-spacing:.3px}
|
||||
td{padding:10px 8px;border-bottom:1px solid #1e293b}
|
||||
td.num{text-align:right;font-family:'JetBrains Mono',monospace}
|
||||
tr:hover td{background:#1e293b}
|
||||
.tot-row td{background:#1e1b4b;font-weight:700;color:#a5b4fc;font-size:.95rem;border-top:2px solid #6366f1}
|
||||
.add-form{background:#151e33;padding:14px;border-radius:8px;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.tag{background:#0a0e1a;border:1px solid #334155;color:#94a3b8;padding:2px 8px;border-radius:3px;font-size:.72rem}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="breadcrumb"><a href="/">Home</a> / <a href="/candidates-pool.html">Candidates</a> / Mission Billing</div>
|
||||
<h1>💰 Mission Billing — Simulation Facturation</h1>
|
||||
<div class="sub">Style BASELINE — TJM × Jours × Mois — Commissions apporteur/manager</div>
|
||||
|
||||
<div class="sel-row">
|
||||
<label>Mission:</label>
|
||||
<select id="sel-mission" onchange="loadMission()"></select>
|
||||
<button class="btn" onclick="loadMission()">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="content">Loading missions...</div>
|
||||
|
||||
<script>
|
||||
const TENANT = new URLSearchParams(location.search).get('tenant') || 'weval';
|
||||
let currentMission = null;
|
||||
|
||||
async function init() {
|
||||
const r = await fetch(`/api/em/missions?tenant=${TENANT}`);
|
||||
const d = await r.json();
|
||||
const sel = document.getElementById('sel-mission');
|
||||
sel.innerHTML = (d.missions || []).map(m =>
|
||||
`<option value="${m.id}">${m.mission_code} — ${m.client_name || m.client_code} (${m.consultant_name || '—'})</option>`
|
||||
).join('') || '<option value="">Aucune mission</option>';
|
||||
if (d.missions && d.missions.length) { sel.value = d.missions[0].id; loadMission(); }
|
||||
}
|
||||
|
||||
async function loadMission() {
|
||||
const id = document.getElementById('sel-mission').value;
|
||||
if (!id) return;
|
||||
const r = await fetch(`/api/em/missions/${id}?tenant=${TENANT}`);
|
||||
currentMission = await r.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const m = currentMission;
|
||||
if (!m) return;
|
||||
|
||||
const billing = m.billing || [];
|
||||
const totalDays = billing.reduce((s,b) => s + parseFloat(b.days_worked || 0), 0);
|
||||
const totalGross = billing.reduce((s,b) => s + parseFloat(b.gross_amount || 0), 0);
|
||||
const totalCash = billing.reduce((s,b) => s + parseFloat(b.cash_consultant || 0), 0);
|
||||
const totalChafik = billing.reduce((s,b) => s + parseFloat(b.gross_amount||0) * parseFloat(b.commission_chafik||0), 0);
|
||||
const totalYoussef = billing.reduce((s,b) => s + parseFloat(b.gross_amount||0) * parseFloat(b.commission_youssef||0), 0);
|
||||
|
||||
let html = `
|
||||
<div class="mission-info">
|
||||
<strong style="font-size:1rem;color:#a5b4fc">${m.mission_code}</strong> —
|
||||
<span class="tag">${m.client_name || m.client_code}</span>
|
||||
<span class="tag">Consultant: ${m.consultant_name || '—'}</span>
|
||||
<span class="tag">${m.role || '—'}</span>
|
||||
<span class="tag">TJM: ${parseFloat(m.tjm).toLocaleString('fr-FR')} MAD</span>
|
||||
<span class="tag">Statut: ${m.status}</span>
|
||||
<span class="tag">Start: ${m.start_date || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi"><div class="kpi-val">${totalDays.toFixed(1)}</div><div class="kpi-lbl">Jours total</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${Math.round(totalGross).toLocaleString('fr-FR')}</div><div class="kpi-lbl">Brut (MAD)</div></div>
|
||||
<div class="kpi"><div class="kpi-val" style="color:#10b981">${Math.round(totalCash).toLocaleString('fr-FR')}</div><div class="kpi-lbl">Cash consultant</div></div>
|
||||
<div class="kpi"><div class="kpi-val" style="color:#f59e0b">${Math.round(totalChafik).toLocaleString('fr-FR')}</div><div class="kpi-lbl">Com. Chafik (5%)</div></div>
|
||||
<div class="kpi"><div class="kpi-val" style="color:#a855f7">${Math.round(totalYoussef).toLocaleString('fr-FR')}</div><div class="kpi-lbl">Com. Youssef (25%)</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${billing.length}</div><div class="kpi-lbl">Périodes</div></div>
|
||||
</div>
|
||||
|
||||
<table><thead><tr>
|
||||
<th>Mois</th><th>J/Homme</th><th>TJM</th><th>Brut</th>
|
||||
<th>Com. Chafik</th><th>Com. Youssef</th><th>Cash Consultant</th><th>Facturé</th><th>Payé</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
billing.forEach(b => {
|
||||
const gross = parseFloat(b.gross_amount || 0);
|
||||
const cChafik = gross * parseFloat(b.commission_chafik || 0);
|
||||
const cYoussef = gross * parseFloat(b.commission_youssef || 0);
|
||||
const cash = parseFloat(b.cash_consultant || 0);
|
||||
const month = new Date(b.period_month).toLocaleDateString('fr-FR', {month:'long', year:'numeric'});
|
||||
html += `<tr>
|
||||
<td><strong>${month}</strong></td>
|
||||
<td class="num">${parseFloat(b.days_worked).toFixed(1)}</td>
|
||||
<td class="num">${parseFloat(b.tjm_applied).toLocaleString('fr-FR')}</td>
|
||||
<td class="num">${Math.round(gross).toLocaleString('fr-FR')}</td>
|
||||
<td class="num" style="color:#f59e0b">${Math.round(cChafik).toLocaleString('fr-FR')}</td>
|
||||
<td class="num" style="color:#a855f7">${Math.round(cYoussef).toLocaleString('fr-FR')}</td>
|
||||
<td class="num" style="color:#10b981"><strong>${Math.round(cash).toLocaleString('fr-FR')}</strong></td>
|
||||
<td>${b.invoiced ? '✅' : '—'}</td>
|
||||
<td>${b.paid ? '✅' : '—'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += `<tr class="tot-row">
|
||||
<td>TOTAL</td>
|
||||
<td class="num">${totalDays.toFixed(1)}</td><td>—</td>
|
||||
<td class="num">${Math.round(totalGross).toLocaleString('fr-FR')}</td>
|
||||
<td class="num">${Math.round(totalChafik).toLocaleString('fr-FR')}</td>
|
||||
<td class="num">${Math.round(totalYoussef).toLocaleString('fr-FR')}</td>
|
||||
<td class="num">${Math.round(totalCash).toLocaleString('fr-FR')}</td>
|
||||
<td>—</td><td>—</td>
|
||||
</tr></tbody></table>
|
||||
|
||||
<div class="add-form">
|
||||
<strong style="color:#a5b4fc">+ Ajouter période</strong>
|
||||
<input id="new-period" type="month" value="${new Date().toISOString().substring(0,7)}">
|
||||
<input id="new-days" type="number" step="0.5" placeholder="Jours" style="width:90px">
|
||||
<input id="new-tjm" type="number" placeholder="TJM" value="${m.tjm || 2470}" style="width:100px">
|
||||
<button class="btn" onclick="addPeriod()">Ajouter</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('content').innerHTML = html;
|
||||
}
|
||||
|
||||
async function addPeriod() {
|
||||
const period = document.getElementById('new-period').value + '-01';
|
||||
const days = parseFloat(document.getElementById('new-days').value);
|
||||
const tjm = parseFloat(document.getElementById('new-tjm').value);
|
||||
if (!days || !tjm) { alert('Jours + TJM requis'); return; }
|
||||
const r = await fetch(`/api/em/missions/${currentMission.id}/billing`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({period_month: period, days_worked: days, tjm_applied: tjm})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) { alert(`✅ Facturé: ${Math.round(d.gross).toLocaleString('fr-FR')} MAD (cash ${Math.round(d.cash_consultant).toLocaleString('fr-FR')})`); loadMission(); }
|
||||
else alert('❌ '+d.error);
|
||||
}
|
||||
|
||||
init();
|
||||
</script></body></html>
|
||||
Reference in New Issue
Block a user