1636 lines
79 KiB
PHP
1636 lines
79 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require __DIR__ . '/../src/bootstrap.php';
|
|
demarrer_session();
|
|
|
|
$page = (string) ($_GET['p'] ?? 'kanban');
|
|
$post = $_SERVER['REQUEST_METHOD'] === 'POST';
|
|
if ($post) {
|
|
verifier_csrf();
|
|
}
|
|
|
|
$aucunCompte = static fn (): bool => (int) db()->query('SELECT COUNT(*) FROM utilisateurs')->fetchColumn() === 0;
|
|
|
|
function reponse_json(array $donnees, int $code = 200): never
|
|
{
|
|
http_response_code($code);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($donnees, JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
function vider_import_en_attente(): void
|
|
{
|
|
$imp = $_SESSION['import'] ?? null;
|
|
unset($_SESSION['import']);
|
|
// Un CV du lot garde ses fichiers : seul le lot les retire
|
|
if ($imp && empty($imp['lot'])) {
|
|
vider_dossier_import((string) $imp['dossier']);
|
|
}
|
|
}
|
|
|
|
/** Termine une connexion : session ouverte, échecs oubliés, connexion notée, alerte si nouveau réseau pour un gestionnaire. */
|
|
function finaliser_connexion(array $u): void
|
|
{
|
|
ouvrir_session($u);
|
|
effacer_echecs($u['identifiant']);
|
|
db()->prepare('UPDATE utilisateurs SET derniere_connexion = NOW() WHERE id = ?')->execute([$u['id']]);
|
|
$nouveauReseau = noter_connexion((int) $u['id'], $u['identifiant'], 'reussie');
|
|
if ($u['role'] === 'admin') {
|
|
if ($nouveauReseau) {
|
|
alerter_nouveau_reseau($u);
|
|
}
|
|
// Ménage fait à la connexion d'un gestionnaire, sans attendre la tâche planifiée
|
|
try {
|
|
purger_corbeille();
|
|
purger_connexions();
|
|
} catch (Throwable $ex) {
|
|
consigner_erreur(get_class($ex), $ex->getMessage(), $ex->getFile(), $ex->getLine());
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Après la connexion : la page demandée avant, si elle est de ce site, sinon le Kanban. */
|
|
function rediriger_apres_connexion(): never
|
|
{
|
|
$retour = (string) ($_SESSION['retour'] ?? '');
|
|
unset($_SESSION['retour']);
|
|
header('Location: ' . (preg_match('/^index\.php\?[\w=&%.+\-]*$/', $retour) ? $retour : url('kanban')));
|
|
exit;
|
|
}
|
|
|
|
/** Filtres de la recherche lus dans l'adresse. */
|
|
function filtres_recherche(): array
|
|
{
|
|
return [
|
|
'statut' => (string) ($_GET['statut'] ?? ''),
|
|
'seniorite' => (string) ($_GET['seniorite'] ?? ''),
|
|
'langue' => (string) ($_GET['langue'] ?? ''),
|
|
'nationalite' => in_array($_GET['nationalite'] ?? '', ['ue', 'hors_ue', 'inconnue'], true) ? (string) $_GET['nationalite'] : '',
|
|
'permis' => ($_GET['permis'] ?? '') === 'expire' ? 'expire' : '',
|
|
'archives' => in_array($_GET['archives'] ?? '', ['inclus', 'seuls'], true) ? (string) $_GET['archives'] : '',
|
|
];
|
|
}
|
|
|
|
/** Après un changement qui ferme les sessions du compte : la session en cours suit la nouvelle version. */
|
|
function garder_session_courante(int $utilisateurId): void
|
|
{
|
|
$req = db()->prepare('SELECT session_version FROM utilisateurs WHERE id = ?');
|
|
$req->execute([$utilisateurId]);
|
|
$_SESSION['sv'] = (int) $req->fetchColumn();
|
|
session_regenerate_id(true);
|
|
}
|
|
|
|
/** Redirige vers une page, à une section donnée. */
|
|
function rediriger_ancre(string $page, string $ancre, array $params = []): never
|
|
{
|
|
header('Location: ' . url($page, $params) . '#' . $ancre);
|
|
exit;
|
|
}
|
|
|
|
/** Remet à plat un champ de fichiers multiples : une entrée par fichier, comme pour un champ simple. */
|
|
function fichiers_multiples(?array $champ): array
|
|
{
|
|
$fichiers = [];
|
|
foreach ((array) ($champ['name'] ?? []) as $i => $nom) {
|
|
if (($champ['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
|
continue;
|
|
}
|
|
$fichiers[] = ['name' => $nom, 'type' => $champ['type'][$i] ?? '', 'tmp_name' => $champ['tmp_name'][$i] ?? '',
|
|
'error' => $champ['error'][$i] ?? UPLOAD_ERR_NO_FILE, 'size' => $champ['size'][$i] ?? 0];
|
|
}
|
|
return $fichiers;
|
|
}
|
|
|
|
switch ($page) {
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Premier lancement : création du compte administrateur
|
|
// -----------------------------------------------------------------------
|
|
case 'installation':
|
|
if (!$aucunCompte()) {
|
|
rediriger('connexion');
|
|
}
|
|
$erreur = null;
|
|
if ($post) {
|
|
try {
|
|
if (($_POST['mot_de_passe'] ?? '') !== ($_POST['confirmation'] ?? '')) {
|
|
throw new InvalidArgumentException(t('Les deux mots de passe diffèrent.'));
|
|
}
|
|
$id = creer_utilisateur((string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''), 'admin');
|
|
// Le premier compte active aussi la double authentification avant d'entrer
|
|
commencer_etape($id);
|
|
rediriger('double_authentification');
|
|
} catch (InvalidArgumentException $ex) {
|
|
$erreur = $ex->getMessage();
|
|
}
|
|
}
|
|
vue('installation', ['titre' => t('Installation'), 'erreur' => $erreur]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Connexion, étape 1 : identifiant et mot de passe
|
|
// -----------------------------------------------------------------------
|
|
case 'connexion':
|
|
if ($aucunCompte()) {
|
|
rediriger('installation');
|
|
}
|
|
if (utilisateur()) {
|
|
rediriger('kanban');
|
|
}
|
|
$erreur = null;
|
|
$info = null;
|
|
if (!empty($_SESSION['session_expiree']) && !$post) {
|
|
unset($_SESSION['session_expiree']);
|
|
$info = t('Votre session est terminée. Reconnectez-vous.');
|
|
}
|
|
if ($post) {
|
|
unset($_SESSION['etape']);
|
|
$identifiant = mb_strtolower(trim((string) ($_POST['identifiant'] ?? '')));
|
|
$mdp = (string) ($_POST['mot_de_passe'] ?? '');
|
|
$attente = minutes_blocage($identifiant);
|
|
if ($attente > 0) {
|
|
$req = db()->prepare('SELECT id FROM utilisateurs WHERE identifiant = ?');
|
|
$req->execute([$identifiant]);
|
|
noter_connexion(($id = $req->fetchColumn()) ? (int) $id : null, $identifiant, 'bloquee');
|
|
$erreur = t('Trop d\'essais infructueux. Réessayez dans %d minutes.', $attente);
|
|
} else {
|
|
$req = db()->prepare('SELECT * FROM utilisateurs WHERE identifiant = ?');
|
|
$req->execute([$identifiant]);
|
|
$u = $req->fetch() ?: null;
|
|
// Vérification à durée constante, y compris si le compte n'existe pas
|
|
$empreinte = $u['mot_de_passe'] ?? password_hash(random_bytes(12), algorithme_mot_de_passe());
|
|
$bon = password_verify(mb_substr($mdp, 0, LONGUEUR_MAX_MOT_DE_PASSE), $empreinte) && $u;
|
|
if ($bon && $u['actif']) {
|
|
// Ancien hachage (bcrypt) : il passe en Argon2id sans rien demander
|
|
if (password_needs_rehash($u['mot_de_passe'], algorithme_mot_de_passe())) {
|
|
db()->prepare('UPDATE utilisateurs SET mot_de_passe = ? WHERE id = ?')->execute([hacher_mot_de_passe($mdp), $u['id']]);
|
|
}
|
|
commencer_etape((int) $u['id']);
|
|
rediriger(suite_connexion($u));
|
|
}
|
|
if ($bon) {
|
|
// Le mot de passe est le bon : la personne peut savoir où en est son compte
|
|
noter_connexion((int) $u['id'], $identifiant, $u['en_attente'] ? 'attente' : 'desactive');
|
|
$erreur = $u['en_attente']
|
|
? t('Votre demande de compte attend l\'accord du gestionnaire. Vous pourrez vous connecter ensuite.')
|
|
: t('Ce compte est désactivé. Adressez-vous au gestionnaire de Vivier.');
|
|
} else {
|
|
noter_echec($identifiant);
|
|
noter_connexion($u ? (int) $u['id'] : null, $identifiant, 'mot_de_passe');
|
|
usleep(random_int(300000, 700000));
|
|
$erreur = t('Identifiant ou mot de passe incorrect.');
|
|
}
|
|
}
|
|
}
|
|
vue('connexion', ['titre' => t('Connexion'), 'erreur' => $erreur, 'info' => $info]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Connexion, étape 2 : code de l'application, ou code de secours
|
|
// -----------------------------------------------------------------------
|
|
case 'verification':
|
|
$e = etape_connexion();
|
|
$u = $e ? compte_par_id((int) $e['uid']) : null;
|
|
if (!$u || !$u['actif'] || suite_connexion($u) !== 'verification') {
|
|
rediriger('connexion');
|
|
}
|
|
$erreur = null;
|
|
if ($post) {
|
|
$attente = minutes_blocage($u['identifiant']);
|
|
if ($attente > 0) {
|
|
noter_connexion((int) $u['id'], $u['identifiant'], 'bloquee');
|
|
unset($_SESSION['etape']);
|
|
flash(t('Trop d\'essais infructueux. Réessayez dans %d minutes.', $attente), 'erreur');
|
|
rediriger('connexion');
|
|
}
|
|
$type = verifier_second_facteur($u, (string) ($_POST['code'] ?? ''));
|
|
if ($type !== null) {
|
|
finaliser_connexion($u);
|
|
if ($type === 'secours') {
|
|
$reste = codes_secours_restants(compte_par_id((int) $u['id']) ?? []);
|
|
flash(tn($reste, 'Code de secours accepté. Il vous en reste %d.', 'Code de secours accepté. Il vous en reste %d.')
|
|
. ($reste <= 3 ? ' ' . t('Générez-en de nouveaux dans Mon compte.') : ''), $reste <= 3 ? 'erreur' : 'info');
|
|
}
|
|
rediriger_apres_connexion();
|
|
}
|
|
noter_echec($u['identifiant']);
|
|
noter_connexion((int) $u['id'], $u['identifiant'], 'code');
|
|
$erreur = t('Code incorrect. Vérifiez l\'heure de votre téléphone, ou utilisez un code de secours.');
|
|
}
|
|
vue('verification', ['titre' => t('Vérification'), 'erreur' => $erreur]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Double authentification : première activation, ou nouvelle application pour un compte connecté
|
|
// -----------------------------------------------------------------------
|
|
case 'double_authentification':
|
|
$moi = utilisateur();
|
|
$e = $moi ? null : etape_connexion();
|
|
$u = $moi ? compte_par_id((int) $moi['id']) : ($e ? compte_par_id((int) $e['uid']) : null);
|
|
if (!$u || !$u['actif'] || (!$moi && suite_connexion($u) !== 'double_authentification')) {
|
|
rediriger('connexion');
|
|
}
|
|
// La clé proposée reste la même tant que la page n'est pas confirmée
|
|
if (empty($_SESSION['totp_nouvelle']) || (int) ($_SESSION['totp_nouvelle']['uid'] ?? 0) !== (int) $u['id']) {
|
|
$_SESSION['totp_nouvelle'] = ['uid' => (int) $u['id'], 'cle' => Totp::nouvelleCle()];
|
|
}
|
|
$cle = $_SESSION['totp_nouvelle']['cle'];
|
|
$erreur = null;
|
|
if ($post) {
|
|
try {
|
|
if ($moi) {
|
|
confirmer_mot_de_passe((string) ($_POST['mot_de_passe'] ?? ''));
|
|
}
|
|
$pas = Totp::verifier($cle, (string) ($_POST['code'] ?? ''), null);
|
|
if ($pas === null) {
|
|
throw new InvalidArgumentException(t('Code incorrect. Vérifiez que l\'heure de votre téléphone est juste, puis saisissez le code affiché maintenant.'));
|
|
}
|
|
activer_double_authentification((int) $u['id'], $cle, $pas);
|
|
unset($_SESSION['totp_nouvelle']);
|
|
$codes = generer_codes_secours((int) $u['id']);
|
|
if ($moi) {
|
|
// Nouvelle application : les autres sessions se ferment, celle-ci reste ouverte
|
|
fermer_sessions((int) $u['id'], true);
|
|
journaliser(null, 'securite', 'nouvelle_application', null, $u['identifiant']);
|
|
} else {
|
|
finaliser_connexion(compte_par_id((int) $u['id']));
|
|
}
|
|
$_SESSION['codes_a_montrer'] = $codes;
|
|
rediriger('codes_secours');
|
|
} catch (InvalidArgumentException $ex) {
|
|
$erreur = $ex->getMessage();
|
|
}
|
|
}
|
|
vue('double_authentification', [
|
|
'titre' => t('Double authentification'),
|
|
'cle' => $cle,
|
|
'adresse' => Totp::adresse($cle, $u['identifiant'], (string) (config('nom_app') ?: 'Vivier')),
|
|
'erreur' => $erreur,
|
|
'connecte' => $moi !== null,
|
|
]);
|
|
break;
|
|
|
|
// Codes de secours : montrés une seule fois, juste après leur création
|
|
case 'codes_secours':
|
|
$moi = exiger_connexion();
|
|
$codes = $_SESSION['codes_a_montrer'] ?? null;
|
|
unset($_SESSION['codes_a_montrer']);
|
|
if (!$codes) {
|
|
rediriger('compte');
|
|
}
|
|
vue('codes_secours', ['titre' => t('Codes de secours'), 'codes' => $codes, 'moi' => $moi]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mot de passe donné par le gestionnaire : la personne le remplace avant d'entrer
|
|
// -----------------------------------------------------------------------
|
|
case 'nouveau_mot_de_passe':
|
|
$e = etape_connexion();
|
|
$u = $e ? compte_par_id((int) $e['uid']) : null;
|
|
if (!$u || !$u['actif'] || suite_connexion($u) !== 'nouveau_mot_de_passe') {
|
|
rediriger('connexion');
|
|
}
|
|
$erreur = null;
|
|
if ($post) {
|
|
try {
|
|
$nouveau = (string) ($_POST['nouveau'] ?? '');
|
|
if ($nouveau !== ($_POST['confirmation'] ?? '')) {
|
|
throw new InvalidArgumentException(t('Les deux nouveaux mots de passe diffèrent.'));
|
|
}
|
|
if (password_verify($nouveau, $u['mot_de_passe'])) {
|
|
throw new InvalidArgumentException(t('Choisissez un mot de passe différent de celui que vous avez reçu.'));
|
|
}
|
|
changer_mot_de_passe((int) $u['id'], $nouveau);
|
|
$u = compte_par_id((int) $u['id']);
|
|
commencer_etape((int) $u['id']);
|
|
rediriger(suite_connexion($u));
|
|
} catch (InvalidArgumentException $ex) {
|
|
$erreur = $ex->getMessage();
|
|
}
|
|
}
|
|
vue('nouveau_mot_de_passe', ['titre' => t('Nouveau mot de passe'), 'erreur' => $erreur]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Invitation : la personne crée son compte avec le lien reçu du gestionnaire
|
|
// -----------------------------------------------------------------------
|
|
case 'invitation':
|
|
if ($aucunCompte()) {
|
|
rediriger('installation');
|
|
}
|
|
$jeton = (string) ($_GET['jeton'] ?? $_POST['jeton'] ?? '');
|
|
$inv = invitation($jeton);
|
|
$erreur = null;
|
|
if ($inv && $post) {
|
|
try {
|
|
if (($_POST['mot_de_passe'] ?? '') !== ($_POST['confirmation'] ?? '')) {
|
|
throw new InvalidArgumentException(t('Les deux mots de passe diffèrent.'));
|
|
}
|
|
$id = accepter_invitation($inv, (string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''));
|
|
unset($_SESSION['uid']);
|
|
commencer_etape($id);
|
|
rediriger('double_authentification');
|
|
} catch (InvalidArgumentException $ex) {
|
|
$erreur = $ex->getMessage();
|
|
}
|
|
}
|
|
vue('invitation', ['titre' => t('Rejoindre Vivier'), 'inv' => $inv, 'jeton' => $jeton, 'erreur' => $erreur]);
|
|
break;
|
|
|
|
// L'ancienne page de demande de compte mène désormais à une explication
|
|
case 'inscription':
|
|
vue('inscription', ['titre' => t('Obtenir un accès')]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Guide Sécurité : lisible sans être connecté, depuis la page de connexion
|
|
// -----------------------------------------------------------------------
|
|
case 'securite':
|
|
$langue = (string) ($_GET['langue'] ?? '');
|
|
$aide = aide('securite', isset(LANGUES_INTERFACE[$langue]) ? $langue : langue_courante());
|
|
vue('aide', ['titre' => t(DOCUMENTS_AIDE['securite']), 'guide' => 'securite', 'aide' => $aide]);
|
|
break;
|
|
|
|
case 'deconnexion':
|
|
if ($post) {
|
|
vider_import_en_attente();
|
|
$_SESSION = [];
|
|
session_destroy();
|
|
}
|
|
rediriger('connexion');
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Kanban
|
|
// -----------------------------------------------------------------------
|
|
case 'kanban':
|
|
exiger_connexion();
|
|
vue('kanban', ['titre' => t('Kanban'), 'colonnes' => candidats_kanban()]);
|
|
break;
|
|
|
|
case 'statut':
|
|
exiger_ecriture();
|
|
$ajax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'fetch';
|
|
$id = (int) ($_POST['id'] ?? 0);
|
|
try {
|
|
if (!$post) {
|
|
throw new InvalidArgumentException(t('Requête invalide.'));
|
|
}
|
|
$statut = (string) ($_POST['statut'] ?? '');
|
|
db()->beginTransaction();
|
|
changer_statut($id, $statut, $_POST['motif'] ?? null);
|
|
if (isset($_POST['ordre']) && $_POST['ordre'] !== '') {
|
|
ordonner_colonne($statut, array_filter(array_map('intval', explode(',', (string) $_POST['ordre']))));
|
|
}
|
|
db()->commit();
|
|
if ($ajax) {
|
|
reponse_json(['ok' => true]);
|
|
}
|
|
flash(t('Statut mis à jour.'), 'succes');
|
|
} catch (InvalidArgumentException $ex) {
|
|
if (db()->inTransaction()) {
|
|
db()->rollBack();
|
|
}
|
|
if ($ajax) {
|
|
reponse_json(['ok' => false, 'erreur' => $ex->getMessage()], 422);
|
|
}
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger('profil', ['id' => $id]);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Import d'un profil
|
|
// -----------------------------------------------------------------------
|
|
case 'import':
|
|
exiger_ecriture();
|
|
$erreurs = [];
|
|
if ($post && ($_POST['mode'] ?? '') === 'auto') {
|
|
vider_import_en_attente();
|
|
try {
|
|
if (!Claude::configure()) {
|
|
throw new RuntimeException(t('L\'analyse automatique n\'est pas configurée.'));
|
|
}
|
|
$cv = controler_fichier($_FILES['cv'] ?? [], ['pdf', 'docx', 'odt', 'md', 'txt']);
|
|
$analyse = Claude::analyser($cv);
|
|
$erreurs = Import::valider($analyse['profil']);
|
|
if ($erreurs) {
|
|
array_unshift($erreurs, t('L\'analyse automatique a produit un profil non conforme. Relancez-la, ou passez par les fichiers déjà analysés.'));
|
|
} else {
|
|
$jeton = bin2hex(random_bytes(16));
|
|
$dossier = dossier_stockage('tmp/' . $jeton);
|
|
file_put_contents($dossier . '/profil.json', json_encode($analyse['profil'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
|
$cv['chemin_tmp'] = $dossier . '/cv.' . $cv['ext'];
|
|
move_uploaded_file($cv['tmp'], $cv['chemin_tmp']);
|
|
$rapport = null;
|
|
if ($analyse['rapport'] !== '') {
|
|
$base = preg_replace('/[^a-z0-9]+/', '-', Recherche::normaliser($analyse['profil']['identite']['nom'] . ' ' . $analyse['profil']['identite']['prenom']));
|
|
$rapport = ['nom' => 'rapport-' . trim((string) $base, '-') . '.md', 'ext' => 'md', 'mime' => 'text/markdown',
|
|
'taille' => strlen($analyse['rapport']), 'chemin_tmp' => $dossier . '/rapport.md'];
|
|
file_put_contents($rapport['chemin_tmp'], $analyse['rapport']);
|
|
}
|
|
$photo = Photo::extraire($cv['chemin_tmp'], $cv['ext']);
|
|
if ($photo !== null) {
|
|
file_put_contents($dossier . '/photo.jpg', $photo);
|
|
}
|
|
$_SESSION['import'] = ['dossier' => $jeton, 'cv' => $cv, 'rapport' => $rapport, 'auto' => $analyse['modele'], 'photo' => $photo !== null];
|
|
rediriger('import_relecture');
|
|
}
|
|
} catch (RuntimeException $ex) {
|
|
$erreurs = [$ex->getMessage()];
|
|
}
|
|
} elseif ($post) {
|
|
vider_import_en_attente();
|
|
try {
|
|
$json = controler_fichier($_FILES['profil'] ?? [], ['json']);
|
|
$cv = controler_fichier($_FILES['cv'] ?? [], ['pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']);
|
|
$rapport = fichier_fourni($_FILES['rapport'] ?? null)
|
|
? controler_fichier($_FILES['rapport'], ['md', 'txt', 'pdf', 'docx'])
|
|
: null;
|
|
|
|
$profil = json_decode((string) file_get_contents($json['tmp']), true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new RuntimeException(t('Le fichier de profil n\'est pas un JSON valide : %s.', json_last_error_msg()));
|
|
}
|
|
$erreurs = Import::valider($profil);
|
|
|
|
if (!$erreurs) {
|
|
$jeton = bin2hex(random_bytes(16));
|
|
$dossier = dossier_stockage('tmp/' . $jeton);
|
|
move_uploaded_file($json['tmp'], $dossier . '/profil.json');
|
|
$ranger = static function (?array $f, string $cle) use ($dossier): ?array {
|
|
if ($f !== null) {
|
|
$f['chemin_tmp'] = $dossier . '/' . $cle . '.' . $f['ext'];
|
|
move_uploaded_file($f['tmp'], $f['chemin_tmp']);
|
|
}
|
|
return $f;
|
|
};
|
|
$cv = $ranger($cv, 'cv');
|
|
$rapport = $ranger($rapport, 'rapport');
|
|
$photo = Photo::extraire($cv['chemin_tmp'], $cv['ext']);
|
|
if ($photo !== null) {
|
|
file_put_contents($dossier . '/photo.jpg', $photo);
|
|
}
|
|
$_SESSION['import'] = ['dossier' => $jeton, 'cv' => $cv, 'rapport' => $rapport, 'photo' => $photo !== null];
|
|
rediriger('import_relecture');
|
|
}
|
|
} catch (RuntimeException $ex) {
|
|
$erreurs = [$ex->getMessage()];
|
|
}
|
|
}
|
|
vue('import', ['titre' => t('Importer un profil'), 'erreurs' => $erreurs, 'auto' => Claude::configure(), 'lot' => Lot::nombre()]);
|
|
break;
|
|
|
|
case 'import_relecture':
|
|
exiger_ecriture();
|
|
$imp = $_SESSION['import'] ?? null;
|
|
$fichier = $imp ? dossier_stockage('tmp') . '/' . $imp['dossier'] . '/profil.json' : null;
|
|
if (!$imp || !is_file($fichier)) {
|
|
rediriger('import');
|
|
}
|
|
$profil = json_decode((string) file_get_contents($fichier), true);
|
|
$doublons = Import::doublons(db(), $profil);
|
|
// Même adresse email : c'est la même personne, l'enregistrement est refusé. Téléphone ou nom : à confirmer.
|
|
$bloquant = (bool) array_filter($doublons, static fn ($d) => in_array('email', $d['motifs'], true));
|
|
$enLot = !empty($imp['lot']);
|
|
|
|
if ($post && ($_POST['action'] ?? '') === 'annuler') {
|
|
vider_import_en_attente();
|
|
if ($enLot) {
|
|
rediriger('import_lot');
|
|
}
|
|
flash(t('Import annulé.'));
|
|
rediriger('import');
|
|
}
|
|
if ($post && !$bloquant) {
|
|
if ($doublons && empty($_POST['confirmer_doublon'])) {
|
|
flash(t('Un profil ressemblant existe déjà. Cochez la case pour confirmer qu\'il s\'agit d\'une autre personne.'), 'erreur');
|
|
rediriger('import_relecture');
|
|
}
|
|
$id = enregistrer_import($profil, $imp, !empty($_POST['garder_photo']));
|
|
if ($enLot) {
|
|
Lot::retirer((int) $imp['lot']);
|
|
}
|
|
vider_import_en_attente();
|
|
flash(t('Profil enregistré dans la colonne Reçu.'), 'succes');
|
|
if ($enLot && Lot::nombre() > 0) {
|
|
rediriger('import_lot');
|
|
}
|
|
rediriger('profil', ['id' => $id]);
|
|
}
|
|
vue('import_relecture', [
|
|
'titre' => t('Relecture avant enregistrement'),
|
|
'profil' => $profil,
|
|
'seniorite' => Import::seniorite($profil),
|
|
'doublons' => $doublons,
|
|
'bloquant' => $bloquant,
|
|
'imp' => $imp,
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Import en lot
|
|
// -----------------------------------------------------------------------
|
|
case 'import_lot':
|
|
exiger_ecriture();
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
if ($post && $action === 'deposer') {
|
|
// CV à analyser automatiquement
|
|
$fichiers = fichiers_multiples($_FILES['cv'] ?? null);
|
|
$places = LOT_MAX - Lot::nombre();
|
|
$deposes = 0;
|
|
$refus = [];
|
|
foreach ($fichiers as $f) {
|
|
if ($deposes >= $places) {
|
|
$refus[] = t('%s : le lot est plein (%d CV au plus).', basename((string) $f['name']), LOT_MAX);
|
|
continue;
|
|
}
|
|
try {
|
|
Lot::deposer(controler_fichier($f, ['pdf', 'docx', 'odt', 'md', 'txt']));
|
|
$deposes++;
|
|
} catch (RuntimeException $ex) {
|
|
$refus[] = basename((string) $f['name']) . ' : ' . $ex->getMessage();
|
|
}
|
|
}
|
|
if ($deposes) {
|
|
flash(tn($deposes, '%d CV déposé.', '%d CV déposés.'), 'succes');
|
|
} elseif (!$refus) {
|
|
flash(t('Aucun fichier reçu.'), 'erreur');
|
|
}
|
|
foreach ($refus as $m) {
|
|
flash($m, 'erreur');
|
|
}
|
|
rediriger('import_lot');
|
|
}
|
|
if ($post && $action === 'deposer_analyses') {
|
|
// Profils déjà analysés : JSON, CV et rapports déposés ensemble, associés par leurs noms
|
|
$controles = [];
|
|
$refus = [];
|
|
foreach (fichiers_multiples($_FILES['fichiers'] ?? null) as $f) {
|
|
try {
|
|
$controles[] = controler_fichier($f, ['json', 'pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']);
|
|
} catch (RuntimeException $ex) {
|
|
$refus[] = basename((string) $f['name']) . ' : ' . $ex->getMessage();
|
|
}
|
|
}
|
|
$r = Lot::apparier($controles);
|
|
$places = LOT_MAX - Lot::nombre();
|
|
$deposes = 0;
|
|
foreach ($r['paires'] as $paire) {
|
|
if ($deposes >= $places) {
|
|
$refus[] = t('%s : le lot est plein (%d CV au plus).', $paire['cv']['nom'], LOT_MAX);
|
|
continue;
|
|
}
|
|
Lot::deposerAnalyse($paire['json'], $paire['cv'], $paire['rapport']);
|
|
$deposes++;
|
|
}
|
|
if ($deposes) {
|
|
flash(tn($deposes, '%d profil déposé.', '%d profils déposés.'), 'succes');
|
|
} elseif (!$refus && !$r['erreurs']) {
|
|
flash(t('Aucun fichier reçu.'), 'erreur');
|
|
}
|
|
foreach ([...$refus, ...$r['erreurs']] as $m) {
|
|
flash($m, 'erreur');
|
|
}
|
|
rediriger('import_lot');
|
|
}
|
|
if ($post && $action === 'analyser') {
|
|
$l = Lot::analyser((int) ($_POST['id'] ?? 0));
|
|
flash($l['etat'] === 'pret' ? t('%s : analyse terminée.', $l['nom_fichier']) : $l['nom_fichier'] . ' : ' . $l['erreur'], $l['etat'] === 'pret' ? 'succes' : 'erreur');
|
|
rediriger('import_lot');
|
|
}
|
|
if ($post && $action === 'relire') {
|
|
$l = Lot::ligne((int) ($_POST['id'] ?? 0));
|
|
if ($l && $l['etat'] === 'pret') {
|
|
vider_import_en_attente();
|
|
$_SESSION['import'] = Lot::enAttente($l);
|
|
rediriger('import_relecture');
|
|
}
|
|
rediriger('import_lot');
|
|
}
|
|
if ($post && $action === 'retirer') {
|
|
// La relecture en cours porte peut-être sur ce CV
|
|
if ((int) ($_SESSION['import']['lot'] ?? 0) === (int) ($_POST['id'] ?? 0)) {
|
|
unset($_SESSION['import']);
|
|
}
|
|
Lot::retirer((int) ($_POST['id'] ?? 0));
|
|
flash(t('CV retiré du lot.'), 'succes');
|
|
rediriger('import_lot');
|
|
}
|
|
if ($post && $action === 'tout') {
|
|
unset($_SESSION['import']);
|
|
$r = Lot::toutEnregistrer();
|
|
if ($r['enregistres']) {
|
|
flash(tn($r['enregistres'], '%d profil enregistré dans la colonne Reçu.', '%d profils enregistrés dans la colonne Reçu.'), 'succes');
|
|
}
|
|
if ($r['a_relire']) {
|
|
flash(tn($r['a_relire'], '%d profil ressemble à un profil existant : relisez-le avant de l\'enregistrer.', '%d profils ressemblent à des profils existants : relisez-les avant de les enregistrer.'), 'info');
|
|
}
|
|
if (!$r['enregistres'] && !$r['a_relire']) {
|
|
flash(t('Aucun profil prêt à enregistrer.'), 'info');
|
|
}
|
|
rediriger(Lot::nombre() > 0 ? 'import_lot' : 'kanban');
|
|
}
|
|
vue('import_lot', ['titre' => t('Import en lot'), 'lignes' => Lot::liste(), 'auto' => Claude::configure()]);
|
|
break;
|
|
|
|
case 'import_lot_analyse':
|
|
// Appelé par le JavaScript de la page du lot : un CV par requête
|
|
exiger_ecriture();
|
|
if (!$post) {
|
|
reponse_json(['ok' => false, 'erreur' => t('Requête invalide.')], 405);
|
|
}
|
|
// L'analyse dure une à deux minutes : la session est rendue tout de suite, pour ne pas bloquer les autres pages
|
|
session_write_close();
|
|
try {
|
|
$l = Lot::analyser((int) ($_POST['id'] ?? 0));
|
|
} catch (RuntimeException $ex) {
|
|
reponse_json(['ok' => false, 'erreur' => $ex->getMessage()], 404);
|
|
}
|
|
reponse_json(['ok' => $l['etat'] === 'pret', 'etat' => $l['etat'], 'candidat' => $l['candidat'], 'erreur' => $l['erreur']]);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Fiche profil, commentaires, documents
|
|
// -----------------------------------------------------------------------
|
|
case 'profil':
|
|
exiger_connexion();
|
|
$c = candidat((int) ($_GET['id'] ?? 0));
|
|
if (!$c) {
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Profil introuvable')]);
|
|
break;
|
|
}
|
|
// Le contenu s'affiche dans la langue de l'interface quand une traduction à jour existe ; « original=1 » montre le texte d'origine
|
|
$original = ($_GET['original'] ?? '') === '1';
|
|
$affiche = $original ? $c + ['traduction' => traduction_profil($c, langue_courante())] : profil_affiche($c, langue_courante());
|
|
vue('profil', [
|
|
'titre' => $c['prenom'] . ' ' . $c['nom'],
|
|
'c' => $affiche,
|
|
'original' => $original,
|
|
'enFile' => profil_en_file((int) $c['id'], langue_courante()),
|
|
'besoinsOuverts' => besoins_ouverts(),
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Suivi : archivage, entretiens, relances, consentement, export des données
|
|
// -----------------------------------------------------------------------
|
|
case 'archiver':
|
|
exiger_ecriture();
|
|
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
|
|
if (!$c) {
|
|
rediriger('kanban');
|
|
}
|
|
$archiver = ($_POST['action'] ?? '') !== 'desarchiver';
|
|
archiver_candidat($c, $archiver);
|
|
flash($archiver ? t('Profil archivé. Il reste dans Profils, filtre « Archivés ».') : t('Profil désarchivé. Il est de retour dans le Kanban.'), 'succes');
|
|
rediriger('profil', ['id' => $c['id']]);
|
|
|
|
case 'entretien':
|
|
exiger_ecriture();
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
$ancien = in_array($action, ['modifier', 'supprimer'], true) ? entretien((int) ($_POST['entretien_id'] ?? 0)) : null;
|
|
$c = $post ? candidat((int) ($ancien['candidat_id'] ?? $_POST['id'] ?? 0)) : null;
|
|
if (!$c) {
|
|
rediriger('kanban');
|
|
}
|
|
try {
|
|
if ($action === 'supprimer' && $ancien) {
|
|
supprimer_entretien($ancien);
|
|
flash(t('Entretien supprimé.'), 'succes');
|
|
} elseif ($action === 'modifier' && $ancien) {
|
|
modifier_entretien($ancien, $_POST);
|
|
flash(t('Entretien mis à jour.'), 'succes');
|
|
} elseif ($action === 'ajouter') {
|
|
db()->beginTransaction();
|
|
ajouter_entretien($c, $_POST);
|
|
// Premier entretien : le profil avance dans le Kanban si la case est cochée
|
|
if (!empty($_POST['avancer']) && in_array($c['statut'], ['recu', 'en_cours'], true) && $c['archive_le'] === null) {
|
|
changer_statut((int) $c['id'], 'entretiens', null);
|
|
}
|
|
db()->commit();
|
|
flash(t('Entretien enregistré.'), 'succes');
|
|
}
|
|
} catch (InvalidArgumentException $ex) {
|
|
if (db()->inTransaction()) {
|
|
db()->rollBack();
|
|
}
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
header('Location: ' . url('profil', ['id' => $c['id']]) . '#entretiens');
|
|
exit;
|
|
|
|
case 'relance':
|
|
exiger_ecriture();
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
$r = in_array($action, ['faite', 'supprimer'], true) ? relance((int) ($_POST['relance_id'] ?? 0)) : null;
|
|
$c = $post ? candidat((int) ($r['candidat_id'] ?? $_POST['id'] ?? 0)) : null;
|
|
if (!$c) {
|
|
rediriger('suivi');
|
|
}
|
|
try {
|
|
if ($action === 'faite' && $r) {
|
|
terminer_relance($r);
|
|
flash(t('Relance faite.'), 'succes');
|
|
} elseif ($action === 'supprimer' && $r) {
|
|
supprimer_relance($r);
|
|
flash(t('Relance supprimée.'), 'succes');
|
|
} elseif ($action === 'ajouter') {
|
|
ajouter_relance($c, $_POST);
|
|
flash(t('Relance enregistrée.'), 'succes');
|
|
}
|
|
} catch (InvalidArgumentException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
if (($_POST['retour'] ?? '') === 'suivi') {
|
|
rediriger('suivi');
|
|
}
|
|
header('Location: ' . url('profil', ['id' => $c['id']]) . '#relances');
|
|
exit;
|
|
|
|
case 'consentement':
|
|
exiger_ecriture();
|
|
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
|
|
if (!$c) {
|
|
rediriger('kanban');
|
|
}
|
|
try {
|
|
$retirer = ($_POST['action'] ?? '') === 'retirer';
|
|
enregistrer_consentement($c, $retirer ? '' : (string) ($_POST['date'] ?? ''), (string) ($_POST['note'] ?? ''));
|
|
flash($retirer ? t('Accord retiré.') : t('Accord enregistré.'), 'succes');
|
|
} catch (InvalidArgumentException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
header('Location: ' . url('profil', ['id' => $c['id']]) . '#donnees');
|
|
exit;
|
|
|
|
case 'profil_export':
|
|
exiger_ecriture();
|
|
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
|
|
if (!$c) {
|
|
rediriger('kanban');
|
|
}
|
|
envoyer_fichier_temporaire(exporter_donnees_candidat($c), 'donnees-' . strtolower(reference_profil($c)) . '-' . date('Y-m-d') . '.zip', 'application/zip');
|
|
|
|
case 'suivi':
|
|
exiger_connexion();
|
|
vue('suivi', [
|
|
'titre' => t('Suivi'),
|
|
'relances' => relances_ouvertes(),
|
|
'entretiens' => entretiens_a_venir(),
|
|
'sansAvis' => entretiens_sans_avis(),
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Besoins clients
|
|
// -----------------------------------------------------------------------
|
|
case 'besoins':
|
|
exiger_connexion();
|
|
$erreur = null;
|
|
if ($post) {
|
|
exiger_ecriture();
|
|
try {
|
|
$id = enregistrer_besoin(null, $_POST);
|
|
flash(t('Besoin enregistré.'), 'succes');
|
|
rediriger('besoin', ['id' => $id]);
|
|
} catch (InvalidArgumentException $ex) {
|
|
$erreur = $ex->getMessage();
|
|
}
|
|
}
|
|
$statut = (string) ($_GET['statut'] ?? '');
|
|
vue('besoins', ['titre' => t('Besoins clients'), 'liste' => besoins($statut), 'statut' => $statut, 'erreur' => $erreur, 'saisie' => $post ? $_POST : []]);
|
|
break;
|
|
|
|
case 'besoin':
|
|
exiger_connexion();
|
|
$b = besoin((int) ($_GET['id'] ?? $_POST['besoin_id'] ?? 0));
|
|
if (!$b) {
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Besoin introuvable')]);
|
|
break;
|
|
}
|
|
$erreur = null;
|
|
if ($post) {
|
|
exiger_ecriture();
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
$candidatId = (int) ($_POST['candidat_id'] ?? 0);
|
|
try {
|
|
switch ($action) {
|
|
case 'modifier':
|
|
enregistrer_besoin($b, $_POST);
|
|
flash(t('Besoin mis à jour.'), 'succes');
|
|
break;
|
|
case 'statut':
|
|
changer_statut_besoin($b, (string) ($_POST['statut'] ?? ''));
|
|
flash(t('Statut mis à jour.'), 'succes');
|
|
break;
|
|
case 'supprimer':
|
|
supprimer_besoin($b);
|
|
flash(t('Besoin supprimé.'), 'succes');
|
|
rediriger('besoins');
|
|
case 'proposer':
|
|
if (candidat($candidatId)) {
|
|
proposer_candidat($b, $candidatId);
|
|
flash(t('Profil ajouté à ce besoin.'), 'succes');
|
|
}
|
|
break;
|
|
case 'etat':
|
|
changer_proposition($b, $candidatId, (string) ($_POST['etat'] ?? ''), (string) ($_POST['note'] ?? ''));
|
|
flash(t('Proposition mise à jour.'), 'succes');
|
|
break;
|
|
case 'retirer':
|
|
retirer_proposition($b, $candidatId);
|
|
flash(t('Profil retiré de ce besoin.'), 'succes');
|
|
break;
|
|
}
|
|
if (($_POST['retour'] ?? '') === 'profil' && $candidatId) {
|
|
header('Location: ' . url('profil', ['id' => $candidatId]) . '#besoins');
|
|
exit;
|
|
}
|
|
rediriger('besoin', ['id' => $b['id']]);
|
|
} catch (InvalidArgumentException $ex) {
|
|
$erreur = $ex->getMessage();
|
|
}
|
|
}
|
|
vue('besoin', [
|
|
'titre' => $b['client'] . ' : ' . $b['intitule'],
|
|
'b' => $b,
|
|
'suggestions' => $b['statut'] === 'ouvert' ? rapprochement($b) : [],
|
|
'erreur' => $erreur,
|
|
'saisie' => $erreur !== null ? $_POST : $b,
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Sauvegarde (gestionnaire des comptes)
|
|
// -----------------------------------------------------------------------
|
|
case 'sauvegarde':
|
|
$moi = exiger_admin();
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
if (!$post && isset($_GET['telecharger'])) {
|
|
$s = Sauvegarde::ligne((int) $_GET['telecharger']);
|
|
$chemin = $s ? Sauvegarde::chemin($s) : null;
|
|
if ($chemin === null) {
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Sauvegarde introuvable')]);
|
|
break;
|
|
}
|
|
journaliser(null, 'sauvegarde', 'telechargement', null, $s['fichier']);
|
|
header('Content-Type: application/zip');
|
|
header('Content-Length: ' . filesize($chemin));
|
|
header('Content-Disposition: attachment; filename="' . $s['fichier'] . '"');
|
|
header('Cache-Control: private, no-store');
|
|
readfile($chemin);
|
|
exit;
|
|
}
|
|
if ($post && $action === 'lancer') {
|
|
$r = Sauvegarde::executer('manuelle', !empty($_POST['envoyer']), (int) $moi['id']);
|
|
if (!$r['reussie']) {
|
|
flash(t('La sauvegarde a échoué : %s', $r['detail']), 'erreur');
|
|
} else {
|
|
journaliser(null, 'sauvegarde', 'manuelle', null, $r['fichier']);
|
|
flash(t('Sauvegarde faite : %s (%s).', $r['fichier'], Sauvegarde::taille((int) $r['taille'])), 'succes');
|
|
if ($r['envoi'] === 'envoye') {
|
|
flash(t('Email envoyé avec la sauvegarde en pièce jointe.'), 'succes');
|
|
} elseif ($r['envoi'] === 'sans_piece') {
|
|
flash(t('Email envoyé sans pièce jointe : le fichier dépasse la taille admise.'), 'info');
|
|
} elseif ($r['envoi'] === 'echec') {
|
|
flash(t('L\'email n\'est pas parti. %s', $r['detail']), 'erreur');
|
|
}
|
|
}
|
|
rediriger('sauvegarde');
|
|
}
|
|
if ($post && $action === 'essai') {
|
|
if (Sauvegarde::tester()) {
|
|
flash(t('Email d\'essai remis au serveur. Vérifiez les boîtes de réception, courriers indésirables compris.'), 'succes');
|
|
} else {
|
|
flash(t('L\'email d\'essai n\'est pas parti. Vérifiez les adresses dans config/config.php.'), 'erreur');
|
|
}
|
|
rediriger('sauvegarde');
|
|
}
|
|
if ($post && $action === 'supprimer') {
|
|
$s = Sauvegarde::ligne((int) ($_POST['id'] ?? 0));
|
|
if ($s) {
|
|
Sauvegarde::supprimerFichier(db(), $s);
|
|
journaliser(null, 'sauvegarde', 'suppression', $s['fichier'], null);
|
|
flash(t('Copie supprimée du serveur.'), 'succes');
|
|
}
|
|
rediriger('sauvegarde');
|
|
}
|
|
if ($post && $action === 'fichiers') {
|
|
try {
|
|
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
|
|
} catch (InvalidArgumentException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
rediriger('sauvegarde');
|
|
}
|
|
journaliser(null, 'sauvegarde', 'fichiers', null, 'Archive des fichiers');
|
|
envoyer_fichier_temporaire(Sauvegarde::archiverFichiers(), 'vivier-fichiers-' . date('Y-m-d-Hi') . '.zip', 'application/zip');
|
|
}
|
|
vue('sauvegarde', [
|
|
'titre' => t('Sauvegarde'),
|
|
'liste' => Sauvegarde::liste(),
|
|
'etat' => Sauvegarde::etat(),
|
|
'reglages' => Sauvegarde::reglages(),
|
|
'fichiers' => db()->query('SELECT COUNT(*) AS n, COALESCE(SUM(taille_octets), 0) AS octets FROM documents')->fetch(),
|
|
]);
|
|
break;
|
|
|
|
case 'cv':
|
|
case 'cv_word':
|
|
case 'cv_paquet':
|
|
case 'cv_traduire':
|
|
case 'cv_traduction':
|
|
case 'europass':
|
|
case 'europass_word':
|
|
exiger_connexion();
|
|
$c = candidat((int) ($_GET['id'] ?? $_POST['id'] ?? 0));
|
|
if (!$c) {
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Profil introuvable')]);
|
|
break;
|
|
}
|
|
$anonyme = ($_GET['anonyme'] ?? $_POST['anonyme'] ?? '') === '1';
|
|
$source = langue_du_profil($c);
|
|
// Sans choix : la langue de l'interface quand elle n'est pas le français, sinon celle du CV d'origine
|
|
$langue = (string) ($_GET['langue'] ?? $_POST['langue'] ?? '');
|
|
$parDefaut = langue_courante() !== 'fr' && isset(LANGUES_CV[langue_courante()]) ? langue_courante() : $source;
|
|
$langue = isset(LANGUES_CV[$langue]) ? $langue : (isset(LANGUES_CV[$parDefaut]) ? $parDefaut : 'fr');
|
|
$retour = ['id' => $c['id'], 'langue' => $langue] + ($anonyme ? ['anonyme' => 1] : []);
|
|
$pageRetour = ($_POST['format'] ?? '') === 'europass' ? 'europass' : 'cv';
|
|
|
|
// Traduction automatique du contenu
|
|
if ($page === 'cv_traduire') {
|
|
exiger_ecriture();
|
|
try {
|
|
if (!$post || !Claude::configure() || !traduction_necessaire($c, $langue)) {
|
|
throw new RuntimeException(t('La traduction automatique n\'est pas disponible.'));
|
|
}
|
|
traduire_profil($c, $langue);
|
|
flash(t('Contenu traduit. Relisez-le avant d\'envoyer le CV.'), 'succes');
|
|
} catch (RuntimeException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger($pageRetour, $retour);
|
|
}
|
|
|
|
// Traduction par fichier : on télécharge le paquet, on le fait traduire, on le recharge
|
|
if ($page === 'cv_paquet') {
|
|
$nom = 'traduction-' . strtolower(reference_profil($c)) . '-' . $langue . '.json';
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Content-Disposition: attachment; filename="' . $nom . '"');
|
|
header('Cache-Control: private, no-store');
|
|
echo json_encode([
|
|
'type' => 'traduction-vivier', 'profil' => reference_profil($c), 'langue_source' => $source, 'langue_cible' => $langue,
|
|
'contenu' => paquet_traduction($c),
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
|
exit;
|
|
}
|
|
if ($page === 'cv_traduction') {
|
|
exiger_ecriture();
|
|
try {
|
|
$f = controler_fichier($_FILES['traduction'] ?? [], ['json']);
|
|
$recu = json_decode((string) file_get_contents($f['tmp']), true);
|
|
if (!is_array($recu) || ($recu['type'] ?? '') !== 'traduction-vivier' || !isset($recu['contenu'])) {
|
|
throw new RuntimeException(t('Ce fichier n\'est pas une traduction produite pour Vivier.'));
|
|
}
|
|
if (($recu['profil'] ?? '') !== reference_profil($c)) {
|
|
throw new RuntimeException(t('Cette traduction concerne un autre profil (%s).', (string) ($recu['profil'] ?? '?')));
|
|
}
|
|
$cible = (string) ($recu['langue_cible'] ?? $langue);
|
|
if (!isset(LANGUES_CV[$cible]) || !traduction_necessaire($c, $cible)) {
|
|
throw new RuntimeException(t('La langue de cette traduction n\'est pas gérée.'));
|
|
}
|
|
enregistrer_traduction($c, $cible, $recu['contenu'], 'fichier');
|
|
$retour['langue'] = $cible;
|
|
flash(t('Traduction enregistrée.'), 'succes');
|
|
} catch (RuntimeException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger('cv', $retour);
|
|
}
|
|
|
|
$traduction = traduction_profil($c, $langue);
|
|
|
|
// CV Europass : coordonnées affichées sauf demande contraire, photo seulement si on la demande
|
|
if ($page === 'europass' || $page === 'europass_word') {
|
|
$options = [
|
|
'anonyme' => $anonyme,
|
|
'photo' => !$anonyme && ($_GET['photo'] ?? '') === '1' && !empty($c['photo']),
|
|
'coordonnees' => !$anonyme && ($_GET['coordonnees'] ?? '1') !== '0',
|
|
];
|
|
$d = donnees_europass($c, $options, $langue, $traduction['contenu']);
|
|
$version = ($anonyme ? 'CV Europass anonymisé' : 'CV Europass') . ' ' . strtoupper($langue);
|
|
if ($page === 'europass_word') {
|
|
$photo = $d['photo'] !== null ? dossier_stockage() . '/' . $d['photo'] : null;
|
|
$docx = ExportEuropass::generer($d, __DIR__ . '/assets/europass/logo-europass-blanc.png', $photo);
|
|
journaliser((int) $c['id'], 'export', 'cv_europass', null, $version . ' (Word)');
|
|
$nom = preg_replace('/[^\w.\- ()]+/u', '_', $d['fichier']) . '.docx';
|
|
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
header('Content-Length: ' . strlen($docx));
|
|
header('Content-Disposition: attachment; filename="' . $nom . '"; filename*=UTF-8\'\'' . rawurlencode($nom));
|
|
header('Cache-Control: private, no-store');
|
|
echo $docx;
|
|
exit;
|
|
}
|
|
journaliser((int) $c['id'], 'export', 'cv_europass', null, $version . ' (PDF)');
|
|
require VIVIER_RACINE . '/vues/europass.php';
|
|
break;
|
|
}
|
|
|
|
$d = donnees_cv($c, $anonyme, (array) config('societe'), $langue, $traduction['contenu']);
|
|
$version = ($anonyme ? 'CV anonymisé' : 'CV IT Club') . ' ' . strtoupper($langue);
|
|
|
|
if ($page === 'cv_word') {
|
|
$docx = ExportWord::generer($d, __DIR__ . '/assets/logo-itclub-or.png');
|
|
journaliser((int) $c['id'], 'export', $anonyme ? 'cv_anonyme' : 'cv_itclub', null, $version . ' (Word)');
|
|
$nom = preg_replace('/[^\w.\- ()]+/u', '_', $d['fichier']) . '.docx';
|
|
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
header('Content-Length: ' . strlen($docx));
|
|
header('Content-Disposition: attachment; filename="' . $nom . '"; filename*=UTF-8\'\'' . rawurlencode($nom));
|
|
header('Cache-Control: private, no-store');
|
|
echo $docx;
|
|
exit;
|
|
}
|
|
|
|
journaliser((int) $c['id'], 'export', $anonyme ? 'cv_anonyme' : 'cv_itclub', null, $version . ' (PDF)');
|
|
require VIVIER_RACINE . '/vues/cv.php';
|
|
break;
|
|
|
|
case 'commentaire':
|
|
exiger_ecriture();
|
|
$id = (int) ($_POST['id'] ?? 0);
|
|
$texte = trim((string) ($_POST['texte'] ?? ''));
|
|
if ($post && $texte !== '' && candidat($id)) {
|
|
db()->prepare('INSERT INTO commentaires (candidat_id, auteur_id, texte) VALUES (?, ?, ?)')
|
|
->execute([$id, utilisateur()['id'], $texte]);
|
|
journaliser($id, 'commentaire');
|
|
} elseif ($post) {
|
|
flash(t('Le commentaire est vide.'), 'erreur');
|
|
}
|
|
rediriger_fiche($id, 'commentaires');
|
|
|
|
case 'document_ajout':
|
|
exiger_ecriture();
|
|
$id = (int) ($_POST['id'] ?? 0);
|
|
$type = (string) ($_POST['type'] ?? 'autre');
|
|
try {
|
|
if (!$post || !candidat($id) || !isset(TYPES_DOCUMENT[$type])) {
|
|
throw new RuntimeException(t('Requête invalide.'));
|
|
}
|
|
attacher_document($id, $type, controler_fichier($_FILES['fichier'] ?? []));
|
|
flash(t('Document ajouté.'), 'succes');
|
|
} catch (RuntimeException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger_fiche($id, 'documents');
|
|
|
|
case 'document':
|
|
exiger_connexion();
|
|
$d = document((int) ($_GET['id'] ?? 0));
|
|
$chemin = $d ? dossier_stockage() . '/' . $d['chemin'] : null;
|
|
if (!$d || !is_file($chemin)) {
|
|
http_response_code(404);
|
|
exit(t('Document introuvable.'));
|
|
}
|
|
$nom = preg_replace('/[^\w.\- ]+/u', '_', $d['nom_fichier']);
|
|
header('Content-Type: ' . $d['type_mime']);
|
|
header('Content-Length: ' . filesize($chemin));
|
|
header('Content-Disposition: attachment; filename="' . $nom . '"');
|
|
header('Cache-Control: private, no-store');
|
|
readfile($chemin);
|
|
exit;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Comptes (administrateur)
|
|
// -----------------------------------------------------------------------
|
|
case 'utilisateurs':
|
|
$moi = exiger_admin();
|
|
$cible = (int) ($_POST['id'] ?? 0);
|
|
$action = $post ? (string) ($_POST['action'] ?? '') : '';
|
|
try {
|
|
switch ($action) {
|
|
case 'inviter':
|
|
$jeton = creer_invitation((string) ($_POST['nom'] ?? ''));
|
|
// Le lien n'est montré qu'une fois : seule son empreinte est enregistrée
|
|
$_SESSION['invitation_lien'] = ['lien' => adresse_complete('invitation', ['jeton' => $jeton]), 'nom' => trim((string) ($_POST['nom'] ?? ''))];
|
|
rediriger_ancre('utilisateurs', 'invitations');
|
|
case 'annuler_invitation':
|
|
flash(annuler_invitation((int) ($_POST['invitation'] ?? 0)) ? t('Invitation annulée : le lien ne fonctionne plus.') : t('Cette invitation n\'existe plus.'), 'succes');
|
|
rediriger_ancre('utilisateurs', 'invitations');
|
|
case 'creer':
|
|
$id = creer_utilisateur((string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''), 'recruteur', true);
|
|
journaliser(null, 'compte', 'creation', null, mb_strtolower(trim((string) $_POST['identifiant'])));
|
|
flash(t('Compte créé. Transmettez l\'identifiant et le mot de passe à la personne : elle le changera à sa première connexion, puis activera la double authentification.'), 'succes');
|
|
break;
|
|
case 'accepter':
|
|
case 'refuser':
|
|
$d = $action === 'accepter' ? accepter_demande($cible) : refuser_demande($cible);
|
|
if ($d === null) {
|
|
flash(t('Cette demande n\'existe plus.'), 'erreur');
|
|
} else {
|
|
flash($action === 'accepter'
|
|
? t('Compte %s accepté. Prévenez la personne : elle peut se connecter.', $d['identifiant'])
|
|
: t('Demande de %s refusée et effacée.', $d['identifiant']), 'succes');
|
|
}
|
|
break;
|
|
case 'debloquer':
|
|
$cle = (string) ($_POST['cle'] ?? '');
|
|
if (debloquer($cle)) {
|
|
journaliser(null, 'compte', 'deblocage', null, preg_replace('/^(compte|ip):/', '', $cle));
|
|
flash(t('Blocage levé. La connexion est de nouveau possible.'), 'succes');
|
|
}
|
|
rediriger_ancre('utilisateurs', 'blocages');
|
|
case 'reindexer':
|
|
flash(tn(Recherche::toutIndexer(db()), '%d profil réindexé.', '%d profils réindexés.'), 'succes');
|
|
rediriger_ancre('utilisateurs', 'maintenance');
|
|
case 'traduire_tout':
|
|
relancer_traductions_en_echec();
|
|
planifier_toutes_traductions();
|
|
$n = nombre_traductions_a_faire();
|
|
flash($n ? tn($n, '%d traduction à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.', '%d traductions à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.')
|
|
: t('Tous les profils sont déjà traduits.'), 'succes');
|
|
rediriger_ancre('utilisateurs', 'maintenance');
|
|
}
|
|
// Les actions suivantes portent sur un compte déjà accepté
|
|
if (in_array($action, ['modifier', 'gestionnaire', 'basculer', 'double_auth'], true)) {
|
|
$compte = compte_par_id($cible);
|
|
if (!$compte || $compte['en_attente']) {
|
|
throw new InvalidArgumentException(t('Compte introuvable.'));
|
|
}
|
|
$soi = $cible === (int) $moi['id'];
|
|
if ($action === 'modifier') {
|
|
modifier_utilisateur($cible, (string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''));
|
|
if (($_POST['mot_de_passe'] ?? '') !== '') {
|
|
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
|
|
// Mot de passe donné par le gestionnaire : la personne le change à sa prochaine connexion
|
|
changer_mot_de_passe($cible, (string) $_POST['mot_de_passe'], !$soi);
|
|
if ($soi) {
|
|
garder_session_courante($cible);
|
|
}
|
|
journaliser(null, 'compte', 'mot_de_passe', null, $compte['identifiant']);
|
|
}
|
|
flash(t('Compte modifié.'), 'succes');
|
|
} elseif ($action === 'gestionnaire') {
|
|
if ($soi) {
|
|
throw new InvalidArgumentException(t('Vous ne pouvez pas retirer votre propre gestion des comptes.'));
|
|
}
|
|
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
|
|
db()->prepare("UPDATE utilisateurs SET role = IF(role = 'admin', 'recruteur', 'admin') WHERE id = ?")->execute([$cible]);
|
|
journaliser(null, 'compte', $compte['role'] === 'admin' ? 'gestionnaire_retire' : 'gestionnaire', null, $compte['identifiant']);
|
|
flash($compte['role'] === 'admin' ? t('Gestion des comptes retirée.') : t('Gestion des comptes confiée.'), 'succes');
|
|
} elseif ($action === 'double_auth') {
|
|
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
|
|
reinitialiser_double_authentification($cible);
|
|
if ($soi) {
|
|
$_SESSION = [];
|
|
rediriger('connexion');
|
|
}
|
|
journaliser(null, 'compte', 'double_auth', null, $compte['identifiant']);
|
|
flash(t('Double authentification remise à zéro. La personne en active une nouvelle à sa prochaine connexion. Ses sessions ouvertes sont fermées.'), 'succes');
|
|
} elseif ($action === 'basculer') {
|
|
if ($soi) {
|
|
throw new InvalidArgumentException(t('Vous ne pouvez pas désactiver votre propre compte.'));
|
|
}
|
|
db()->prepare('UPDATE utilisateurs SET actif = 1 - actif, session_version = session_version + 1 WHERE id = ?')->execute([$cible]);
|
|
journaliser(null, 'compte', $compte['actif'] ? 'desactivation' : 'reactivation', null, $compte['identifiant']);
|
|
flash($compte['actif'] ? t('Compte désactivé : il ne peut plus se connecter et ses sessions sont fermées.') : t('Compte réactivé.'), 'succes');
|
|
}
|
|
}
|
|
} catch (InvalidArgumentException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
if ($post) {
|
|
rediriger('utilisateurs');
|
|
}
|
|
$lien = $_SESSION['invitation_lien'] ?? null;
|
|
unset($_SESSION['invitation_lien']);
|
|
vue('utilisateurs', [
|
|
'titre' => t('Comptes'),
|
|
'liste' => utilisateurs(),
|
|
'demandes' => demandes_compte(),
|
|
'moi' => $moi,
|
|
'invitations' => invitations_ouvertes(),
|
|
'lien' => $lien,
|
|
'blocages' => blocages_en_cours(),
|
|
'aTraduire' => nombre_traductions_a_faire(),
|
|
'echecsTraduction' => traductions_en_echec(),
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Journal des connexions et erreurs (gestionnaire)
|
|
// -----------------------------------------------------------------------
|
|
case 'connexions':
|
|
exiger_admin();
|
|
$qui = (int) ($_GET['utilisateur'] ?? 0);
|
|
$resultat = (string) ($_GET['resultat'] ?? '');
|
|
vue('connexions', ['titre' => t('Connexions'), 'lignes' => connexions($qui, $resultat), 'comptes' => utilisateurs(), 'qui' => $qui, 'resultat' => $resultat]);
|
|
break;
|
|
|
|
case 'erreurs':
|
|
exiger_admin();
|
|
if ($post && ($_POST['action'] ?? '') === 'vider') {
|
|
vider_erreurs();
|
|
flash(t('Journal des erreurs vidé.'), 'succes');
|
|
rediriger('erreurs');
|
|
}
|
|
$noms = db()->query('SELECT id, nom_affiche FROM utilisateurs')->fetchAll(PDO::FETCH_KEY_PAIR);
|
|
vue('erreurs', ['titre' => t('Erreurs'), 'lignes' => erreurs_notees(), 'noms' => $noms]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Traduction des profils : la suivante de la file (appelée par le script, en arrière-plan),
|
|
// ou tout de suite, depuis la fiche
|
|
// -----------------------------------------------------------------------
|
|
case 'traduction_suivante':
|
|
$moi = exiger_ecriture();
|
|
if (!$post || !Claude::configure()) {
|
|
reponse_json(['ok' => false, 'reste' => 0]);
|
|
}
|
|
session_write_close();
|
|
$r = traduction_suivante((int) $moi['id']);
|
|
reponse_json(['ok' => $r['erreur'] === null, 'fait' => $r['fait'], 'reste' => $r['reste'], 'erreur' => $r['erreur']]);
|
|
|
|
case 'traduire_profil':
|
|
$moi = exiger_ecriture();
|
|
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
|
|
if (!$c) {
|
|
rediriger('kanban');
|
|
}
|
|
$langue = isset(LANGUES_INTERFACE[$_POST['langue'] ?? '']) ? (string) $_POST['langue'] : langue_courante();
|
|
try {
|
|
traduire_profil($c, $langue);
|
|
flash(t('Profil traduit. Relisez la traduction : elle est automatique.'), 'succes');
|
|
} catch (RuntimeException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger('profil', ['id' => $c['id']]);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Recherche
|
|
// -----------------------------------------------------------------------
|
|
case 'recherche':
|
|
exiger_connexion();
|
|
$q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 200);
|
|
$filtres = filtres_recherche();
|
|
$r = Recherche::chercher(db(), $q, $filtres);
|
|
$r['resultats'] = traduire_titres($r['resultats']);
|
|
vue('recherche', [
|
|
'titre' => $q !== '' ? t('Recherche : %s', $q) : t('Tous les profils'),
|
|
'q' => $q,
|
|
'filtres' => $filtres,
|
|
'resultats' => $r['resultats'],
|
|
'analyse' => $r['analyse'],
|
|
'langues' => db()->query('SELECT DISTINCT langue FROM langues ORDER BY langue')->fetchAll(PDO::FETCH_COLUMN),
|
|
'cv' => cv_origine_par_candidat(array_column($r['resultats'], 'id')),
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Modification, mise à jour et suppression d'un profil
|
|
// -----------------------------------------------------------------------
|
|
case 'profil_modifier':
|
|
exiger_ecriture();
|
|
$c = candidat((int) ($_GET['id'] ?? $_POST['id'] ?? 0));
|
|
if (!$c) {
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Profil introuvable')]);
|
|
break;
|
|
}
|
|
$erreur = null;
|
|
if ($post) {
|
|
try {
|
|
$n = modifier_candidat($c, $_POST);
|
|
flash($n ? tn($n, '%d champ modifié.', '%d champs modifiés.') : t('Aucun changement.'), $n ? 'succes' : 'info');
|
|
rediriger('profil', ['id' => $c['id']]);
|
|
} catch (InvalidArgumentException $ex) {
|
|
$erreur = $ex->getMessage();
|
|
}
|
|
}
|
|
vue('profil_modifier', ['titre' => t('Modifier %s', $c['prenom'] . ' ' . $c['nom']), 'c' => $c, 'erreur' => $erreur, 'saisie' => $post ? $_POST : $c]);
|
|
break;
|
|
|
|
case 'profil_maj':
|
|
$moi = exiger_ecriture();
|
|
$c = candidat((int) ($_POST['id'] ?? 0));
|
|
if (!$post || !$c) {
|
|
rediriger('kanban');
|
|
}
|
|
try {
|
|
$json = controler_fichier($_FILES['profil'] ?? [], ['json']);
|
|
$cv = fichier_fourni($_FILES['cv'] ?? null) ? controler_fichier($_FILES['cv'], ['pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']) : null;
|
|
$rapport = fichier_fourni($_FILES['rapport'] ?? null) ? controler_fichier($_FILES['rapport'], ['md', 'txt', 'pdf', 'docx']) : null;
|
|
$profil = json_decode((string) file_get_contents($json['tmp']), true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new RuntimeException(t('Le fichier de profil n\'est pas un JSON valide.'));
|
|
}
|
|
$erreurs = Import::valider($profil);
|
|
if ($erreurs) {
|
|
throw new RuntimeException(t('Mise à jour refusée : %s', implode(' ', array_slice($erreurs, 0, 5)) . (count($erreurs) > 5 ? ' …' : '')));
|
|
}
|
|
$autre = Import::doublon(db(), $profil);
|
|
if ($autre !== null && $autre !== (int) $c['id']) {
|
|
throw new RuntimeException(t('L\'adresse email de ce fichier appartient à un autre profil.'));
|
|
}
|
|
$pdo = db();
|
|
$pdo->beginTransaction();
|
|
try {
|
|
Import::remplacer($pdo, (int) $c['id'], $profil, (int) $moi['id']);
|
|
journaliser((int) $c['id'], 'modification', 'Analyse', null, 'Mise à jour depuis ' . $profil['source']['fichier_origine']);
|
|
if ($cv) {
|
|
// Photo reprise du nouveau CV seulement si la fiche n'en a pas : un choix fait à la main n'est pas écrasé
|
|
$photo = empty($c['photo']) ? Photo::extraire($cv['tmp'], $cv['ext']) : null;
|
|
attacher_document((int) $c['id'], 'cv_origine', $cv);
|
|
if ($photo !== null) {
|
|
Photo::enregistrer($pdo, (int) $c['id'], $photo);
|
|
}
|
|
}
|
|
if ($rapport) {
|
|
attacher_document((int) $c['id'], 'rapport', $rapport);
|
|
}
|
|
$pdo->commit();
|
|
} catch (Throwable $ex) {
|
|
$pdo->rollBack();
|
|
throw $ex;
|
|
}
|
|
flash(t('Profil mis à jour. Le statut, les commentaires et les documents sont conservés.'), 'succes');
|
|
} catch (RuntimeException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger('profil', ['id' => $c['id']]);
|
|
|
|
// Suppression : réservée au gestionnaire. Le profil part à la corbeille, d'où il est effacé après 30 jours.
|
|
case 'profil_suppr':
|
|
exiger_admin();
|
|
$c = candidat((int) ($_POST['id'] ?? 0));
|
|
if ($post && $c) {
|
|
mettre_a_la_corbeille($c);
|
|
flash(t('Profil %s mis à la corbeille. Il sera effacé pour de bon dans %d jours.', reference_profil($c), CORBEILLE_JOURS), 'succes');
|
|
}
|
|
rediriger(($_POST['retour'] ?? '') === 'conservation' ? 'conservation' : 'kanban');
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Corbeille (gestionnaire) : restaurer un profil ou l'effacer sans attendre
|
|
// -----------------------------------------------------------------------
|
|
case 'corbeille':
|
|
exiger_admin();
|
|
if ($post) {
|
|
$c = candidat((int) ($_POST['id'] ?? 0), true);
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
if (!$c || $c['supprime_le'] === null) {
|
|
flash(t('Ce profil n\'est plus dans la corbeille.'), 'erreur');
|
|
} elseif ($action === 'restaurer') {
|
|
restaurer_candidat($c);
|
|
flash(t('Profil %s restauré.', $c['prenom'] . ' ' . $c['nom']), 'succes');
|
|
} elseif ($action === 'effacer') {
|
|
supprimer_candidat($c);
|
|
flash(t('Profil %s effacé pour de bon, avec ses documents.', reference_profil($c)), 'succes');
|
|
}
|
|
rediriger('corbeille');
|
|
}
|
|
$effaces = purger_corbeille();
|
|
if ($effaces) {
|
|
flash(t($effaces > 1 ? '%d profils effacés : leur délai de %d jours était passé.' : '%d profil effacé : son délai de %d jours était passé.', $effaces, CORBEILLE_JOURS), 'info');
|
|
}
|
|
vue('corbeille', ['titre' => t('Corbeille'), 'liste' => traduire_titres(corbeille())]);
|
|
break;
|
|
|
|
case 'document_suppr':
|
|
exiger_ecriture();
|
|
$d = document((int) ($_POST['id'] ?? 0));
|
|
if ($post && $d) {
|
|
supprimer_document($d);
|
|
flash(t('Document supprimé.'), 'succes');
|
|
rediriger_fiche((int) $d['candidat_id'], 'documents');
|
|
}
|
|
rediriger('kanban');
|
|
|
|
case 'mot_cle':
|
|
exiger_ecriture();
|
|
$id = (int) ($_POST['id'] ?? 0);
|
|
if ($post && candidat($id)) {
|
|
$pdo = db();
|
|
if (($_POST['action'] ?? '') === 'supprimer') {
|
|
$pdo->prepare('DELETE FROM mots_cles WHERE id = ? AND candidat_id = ?')->execute([(int) ($_POST['mot_id'] ?? 0), $id]);
|
|
} else {
|
|
$type = (string) ($_POST['type'] ?? 'mot_cle');
|
|
$mot = mb_strtolower(mb_substr(trim((string) ($_POST['mot'] ?? '')), 0, 80));
|
|
if ($mot !== '' && isset(TYPES_MOT[$type])) {
|
|
$pdo->prepare('INSERT IGNORE INTO mots_cles (candidat_id, type, mot, manuel) VALUES (?, ?, ?, 1)')->execute([$id, $type, $mot]);
|
|
journaliser($id, 'modification', TYPES_MOT[$type], null, $mot);
|
|
}
|
|
}
|
|
Recherche::indexer($pdo, $id);
|
|
}
|
|
rediriger_fiche($id, 'mots-cles');
|
|
|
|
case 'rapport':
|
|
exiger_connexion();
|
|
$d = document((int) ($_GET['id'] ?? 0));
|
|
$chemin = $d ? dossier_stockage() . '/' . $d['chemin'] : null;
|
|
if (!$d || !is_file($chemin) || !in_array(strtolower(pathinfo($d['nom_fichier'], PATHINFO_EXTENSION)), ['md', 'txt'], true)) {
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Rapport introuvable')]);
|
|
break;
|
|
}
|
|
vue('rapport', [
|
|
'titre' => $d['nom_fichier'],
|
|
'd' => $d,
|
|
'c' => candidat((int) $d['candidat_id']),
|
|
'html' => markdown_vers_html((string) file_get_contents($chemin)),
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mon compte
|
|
// -----------------------------------------------------------------------
|
|
case 'compte':
|
|
$moi = exiger_connexion();
|
|
if ($post) {
|
|
$action = (string) ($_POST['action'] ?? 'mot_de_passe');
|
|
try {
|
|
if ($action === 'mot_de_passe') {
|
|
confirmer_mot_de_passe((string) ($_POST['actuel'] ?? ''));
|
|
if (($_POST['nouveau'] ?? '') !== ($_POST['confirmation'] ?? '')) {
|
|
throw new InvalidArgumentException(t('Les deux nouveaux mots de passe diffèrent.'));
|
|
}
|
|
changer_mot_de_passe((int) $moi['id'], (string) ($_POST['nouveau'] ?? ''));
|
|
garder_session_courante((int) $moi['id']);
|
|
journaliser(null, 'securite', 'mot_de_passe', null, $moi['identifiant']);
|
|
flash(t('Mot de passe modifié. Vos autres sessions sont fermées.'), 'succes');
|
|
} elseif ($action === 'codes') {
|
|
confirmer_mot_de_passe((string) ($_POST['mot_de_passe'] ?? ''));
|
|
$_SESSION['codes_a_montrer'] = generer_codes_secours((int) $moi['id']);
|
|
journaliser(null, 'securite', 'codes_secours', null, $moi['identifiant']);
|
|
rediriger('codes_secours');
|
|
} elseif ($action === 'sessions') {
|
|
fermer_sessions((int) $moi['id'], true);
|
|
journaliser(null, 'securite', 'sessions', null, $moi['identifiant']);
|
|
flash(t('Vos autres sessions sont fermées. Celle-ci reste ouverte.'), 'succes');
|
|
}
|
|
} catch (InvalidArgumentException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger('compte');
|
|
}
|
|
vue('compte', [
|
|
'titre' => t('Mon compte'),
|
|
'moi' => $moi,
|
|
'compte' => compte_par_id((int) $moi['id']),
|
|
'derniere' => connexions((int) $moi['id'], 'reussie', 5),
|
|
]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Conservation des données et journal (administrateur)
|
|
// -----------------------------------------------------------------------
|
|
case 'conservation':
|
|
exiger_admin();
|
|
if ($post && ($_POST['action'] ?? '') === 'prolonger') {
|
|
$c = candidat((int) ($_POST['id'] ?? 0));
|
|
if ($c) {
|
|
prolonger_conservation($c, (int) config('conservation_mois'));
|
|
flash(t('Conservation prolongée de %d mois.', (int) config('conservation_mois')), 'succes');
|
|
}
|
|
rediriger('conservation');
|
|
}
|
|
vue('conservation', ['titre' => t('Conservation'), 'liste' => traduire_titres(candidats_a_echeance())]);
|
|
break;
|
|
|
|
case 'journal':
|
|
exiger_admin();
|
|
$qui = (int) ($_GET['utilisateur'] ?? 0);
|
|
vue('journal', ['titre' => t('Journal'), 'lignes' => journal($qui ?: null), 'comptes' => utilisateurs(), 'qui' => $qui]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Export CSV de la liste et tableau de bord
|
|
// -----------------------------------------------------------------------
|
|
case 'export_csv':
|
|
exiger_admin();
|
|
$r = Recherche::chercher(db(), mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 200), filtres_recherche());
|
|
$lignes = lignes_export(array_map('intval', array_column($r['resultats'], 'id')));
|
|
journaliser(null, 'export', 'csv', null, 'Liste CSV (' . count($lignes) . ' profils)');
|
|
|
|
header('Content-Type: text/csv; charset=utf-8');
|
|
header('Content-Disposition: attachment; filename="vivier-profils-' . date('Y-m-d') . '.csv"');
|
|
header('Cache-Control: private, no-store');
|
|
$sortie = fopen('php://output', 'w');
|
|
fwrite($sortie, "\xEF\xBB\xBF"); // marque UTF-8 : Excel affiche les accents
|
|
// Une cellule qui commence par = + - @ serait lue comme une formule par un tableur
|
|
$neutre = static fn (string $v): string => preg_match('/^[=+\-@\t\r]/', $v) ? "'" . $v : $v;
|
|
if ($lignes) {
|
|
fputcsv($sortie, array_map('t', array_keys($lignes[0])), ';', '"', '');
|
|
}
|
|
foreach ($lignes as $l) {
|
|
fputcsv($sortie, array_map($neutre, array_values($l)), ';', '"', '');
|
|
}
|
|
exit;
|
|
|
|
case 'tableau':
|
|
exiger_connexion();
|
|
vue('tableau', ['titre' => t('Tableau de bord'), 's' => statistiques(), 'sauvegarde' => utilisateur()['role'] === 'admin' ? Sauvegarde::etat() : null]);
|
|
break;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Langue de l'interface
|
|
// -----------------------------------------------------------------------
|
|
case 'langue':
|
|
$langue = (string) ($_POST['langue'] ?? '');
|
|
if ($post && isset(LANGUES_INTERFACE[$langue])) {
|
|
setcookie('vivier_langue', $langue, ['expires' => time() + 365 * 86400, 'path' => '/', 'secure' => (bool) config('https'), 'httponly' => true, 'samesite' => 'Lax']);
|
|
if (utilisateur()) {
|
|
db()->prepare('UPDATE utilisateurs SET langue = ? WHERE id = ?')->execute([$langue, utilisateur()['id']]);
|
|
}
|
|
}
|
|
// Retour à la page d'où vient la demande, si elle est bien de ce site
|
|
$retour = (string) ($_POST['retour'] ?? '');
|
|
header('Location: ' . (preg_match('/^index\.php\?[\w=&%.+\-]*$/', $retour) ? $retour : url('kanban')));
|
|
exit;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Photo du candidat
|
|
// -----------------------------------------------------------------------
|
|
case 'photo':
|
|
exiger_connexion();
|
|
$req = db()->prepare('SELECT photo FROM candidats WHERE id = ? AND (supprime_le IS NULL OR ?)');
|
|
$req->execute([(int) ($_GET['id'] ?? 0), (int) est_admin()]);
|
|
$relatif = $req->fetchColumn();
|
|
$chemin = $relatif ? dossier_stockage() . '/' . $relatif : null;
|
|
if (!$chemin || !is_file($chemin)) {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
header('Content-Type: image/jpeg');
|
|
header('Content-Length: ' . filesize($chemin));
|
|
header('Cache-Control: private, max-age=86400'); // l'adresse change avec la photo
|
|
readfile($chemin);
|
|
exit;
|
|
|
|
case 'import_photo':
|
|
exiger_ecriture();
|
|
$imp = $_SESSION['import'] ?? null;
|
|
$chemin = $imp && preg_match('/^[a-f0-9]{32}$/', $imp['dossier']) ? dossier_stockage('tmp') . '/' . $imp['dossier'] . '/photo.jpg' : null;
|
|
if (!$chemin || !is_file($chemin)) {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
header('Content-Type: image/jpeg');
|
|
header('Cache-Control: private, no-store');
|
|
readfile($chemin);
|
|
exit;
|
|
|
|
case 'photo_maj':
|
|
exiger_ecriture();
|
|
$c = candidat((int) ($_POST['id'] ?? 0));
|
|
if (!$post || !$c) {
|
|
rediriger('kanban');
|
|
}
|
|
try {
|
|
if (($_POST['action'] ?? '') === 'retirer') {
|
|
Photo::supprimer(db(), (int) $c['id']);
|
|
journaliser((int) $c['id'], 'modification', 'Photo', null, 'Photo retirée');
|
|
flash(t('Photo retirée.'), 'succes');
|
|
} else {
|
|
$f = controler_fichier($_FILES['photo'] ?? [], ['jpg', 'jpeg', 'png', 'webp', 'gif']);
|
|
$jpeg = Photo::normaliser((string) file_get_contents($f['tmp']));
|
|
if ($jpeg === null) {
|
|
throw new RuntimeException(t('Cette image est illisible. Chargez un fichier JPEG ou PNG.'));
|
|
}
|
|
Photo::enregistrer(db(), (int) $c['id'], $jpeg);
|
|
journaliser((int) $c['id'], 'modification', 'Photo', null, 'Photo ajoutée');
|
|
flash(t('Photo enregistrée.'), 'succes');
|
|
}
|
|
} catch (RuntimeException $ex) {
|
|
flash($ex->getMessage(), 'erreur');
|
|
}
|
|
rediriger_fiche((int) $c['id'], 'photo');
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Aide : les guides du dossier aide/
|
|
// -----------------------------------------------------------------------
|
|
case 'aide':
|
|
$moi = exiger_connexion();
|
|
$guide = (string) ($_GET['guide'] ?? 'guide');
|
|
if ($guide === 'administration' && $moi['role'] !== 'admin') {
|
|
http_response_code(403);
|
|
exit(t('Page réservée au gestionnaire des comptes.'));
|
|
}
|
|
$langue = (string) ($_GET['langue'] ?? '');
|
|
$aide = aide($guide, isset(LANGUES_INTERFACE[$langue]) ? $langue : langue_courante());
|
|
if ($aide === null) {
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Page introuvable')]);
|
|
break;
|
|
}
|
|
vue('aide', ['titre' => t(DOCUMENTS_AIDE[$guide]), 'guide' => $guide, 'aide' => $aide]);
|
|
break;
|
|
|
|
default:
|
|
exiger_connexion();
|
|
http_response_code(404);
|
|
vue('introuvable', ['titre' => t('Page introuvable')]);
|
|
}
|