commit 839e5e57c5d4c0ff6b684954c737d1bdd9012782 Author: Yann PILLA Date: Fri Oct 16 21:52:44 2026 +0200 Import initial de Vivier depuis l'hebergement OVH diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99c8a30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Secrets propres a chaque installation +config/config.php +config/cle-secrets.php + +# CV, photos et documents des candidats +stockage/** +!stockage/*/ +!stockage/*/.gitkeep + +.DS_Store +._* diff --git a/README.md b/README.md new file mode 100644 index 0000000..5aaa707 --- /dev/null +++ b/README.md @@ -0,0 +1,332 @@ +# Vivier + +Outil de suivi des CV d'IT Club. PHP 8.3, MySQL ou MariaDB, sans framework ni dépendance à installer. + +## Contenu + +| Dossier | Rôle | +|---|---| +| `public/` | Racine web : `index.php`, styles, scripts | +| `src/` | Code PHP : socle, import, accès aux données | +| `vues/` | Écrans | +| `config/` | Configuration (`config.php` à créer) | +| `stockage/` | CV et documents, hors de la racine web | +| `sql/schema.sql` | Création de la base | +| `claude/` | Schéma JSON, exemple et consignes du projet Claude | +| `lang/` | Traductions : interface en anglais, libellés des CV en anglais et en allemand | +| `cron/` | Script de la sauvegarde hebdomadaire, lancé par la tâche planifiée de l'hébergement | +| `aide/` | Guides d'utilisation et d'administration, en français et en anglais, affichés dans Vivier | +| `tests/` | Tests sans base de données | + +## Installation sur l'hébergement OVH + +1. Dans l'espace client, onglet Bases de données : créer une base MySQL. Noter l'hôte, le nom, l'utilisateur et le mot de passe. +2. Ouvrir phpMyAdmin, onglet Importer : charger `sql/schema.sql`. +3. Envoyer tout le dossier `vivier/` par FTP, à côté de `www/` (par exemple `/vivier`). +4. Onglet Multisite : ajouter le sous-domaine choisi, par exemple `vivier.itclub.lu`, avec pour dossier racine `vivier/public`. Cocher SSL. +5. Copier `config/config.exemple.php` en `config/config.php` et renseigner la base. +6. Ouvrir l'adresse du site en `https://`. La page de premier lancement crée votre compte, gestionnaire des comptes, puis fait activer la double authentification. Le dossier `config/` doit être accessible en écriture : Vivier y crée `cle-secrets.php`. Sinon, renseigner `cle_secrets` dans `config/config.php`. +7. Programmer la sauvegarde hebdomadaire : voir la section Sauvegarde. + +Le dossier racine doit être `vivier/public`. Les autres dossiers restent ainsi inaccessibles par URL. Des fichiers `.htaccess` les bloquent aussi en seconde protection. + +## Mise à jour d'une installation existante + +1. Dans phpMyAdmin, exécuter dans l'ordre les fichiers `sql/migration-*.sql` qui n'ont pas encore été passés. +2. Remplacer par FTP les dossiers `public/`, `src/`, `vues/`, `lang/`, `aide/`, `cron/`, `sql/`, `claude/` et `tests/`. Garder `config/config.php` et `stockage/`. +3. Reporter dans `config/config.php` les nouvelles clés de `config/config.exemple.php` (`societe`, `claude`, `sauvegarde`, `comptes`, `adresse`, `cle_secrets`, `securite`). +4. Dans Vivier : Comptes, puis « Réindexer tous les profils » et « Traduire tous les profils ». +5. Dans le projet Claude d'analyse : remplacer les consignes, le schéma et l'exemple par ceux du dossier `claude/`. + +Après la migration 1.2, les anciennes adresses email servent d'identifiants. Le gestionnaire les raccourcit dans Comptes. + +La migration 1.4 réunit « Premier entretien validé » et « Second entretien validé » en « Entretiens en cours ». Les profils concernés changent de colonne. Le journal garde les anciens libellés. Faire une sauvegarde de la base dans phpMyAdmin (onglet Exporter) avant de passer une migration. + +La migration 1.5 ajoute la corbeille des profils et les demandes de compte. Elle crée le compte gestionnaire `ypilla`, ou met à jour son mot de passe et son rôle s'il existe. Le fichier contient l'empreinte du mot de passe, pas le mot de passe. Les autres comptes gardent leur rôle. La clé `comptes` de la configuration est facultative : sans elle, l'email de demande part à l'adresse d'expédition. + +La version 1.6 réorganise la fiche d'un profil : un bandeau (statut, disponibilité, TJM, choses à faire) et quatre onglets, Profil, Suivi, Missions et Dossier. Pas de migration de base. Remplacer `public/`, `src/`, `vues/`, `lang/` et `aide/`. Les adresses des styles et du script changent (`?v=1.6`) : les navigateurs chargent la nouvelle version sans vider leur cache. + +La version 1.7 corrige le survol du nom « Vivier » dans l'en-tête et affine l'usage : noms de famille en capitales, formulaire de la fiche en trois blocs, filtres appliqués dès qu'on les change, filtres repliés sur téléphone, une seule pastille sur Gestion. Passer `sql/migration-1.6-vers-1.7.sql` (noms existants en capitales), puis remplacer `public/`, `src/`, `vues/`, `lang/`, `aide/` et `sql/`. « Non retenu » passe en dernière colonne du Kanban, après « Embauché ». + +La version 1.8 renforce la sécurité des accès, affiche les profils dans la langue de l'interface et ajoute la nationalité, le permis de travail et le CV Europass. + +- Sécurité : double authentification obligatoire (application TOTP, dix codes de secours), accès sur lien d'invitation à la place de la demande publique, blocages progressifs par identifiant et par adresse, HTTPS obligatoire avec HSTS, sessions de 12 heures au plus, journal des connexions de 12 mois, alerte email quand un gestionnaire se connecte depuis un réseau nouveau, mots de passe contrôlés contre les fuites connues et hachés en Argon2id, export CSV, journal et conservation réservés au gestionnaire, mot de passe redemandé pour les actions sensibles, journal des erreurs. Nouveau guide « Sécurité », lisible depuis la page de connexion. +- Traduction : chaque profil s'affiche traduit dans la langue de l'interface, en arrière-plan. La recherche trouve les textes d'origine et traduits. +- Disponibilité : liste fixe (Immédiate, Sous 1, 2 ou 3 mois, Date précise, À préciser), avec une note libre. +- Nationalité (codes ISO, plusieurs possibles), filtre UE ou hors UE, permis de travail en texte libre avec date d'expiration et relance 2 mois avant. Sur aucun CV. +- CV Europass nominatif ou anonymisé, en Word et en PDF, en trois langues. Nouveaux champs : projet, mission et charge des expériences, dates et lieu des formations, grille européenne des langues, bénévolat, permis de conduire. +- Format d'échange avec Claude en version 1.2 : remplacer les consignes, le schéma et l'exemple du projet Claude d'analyse. +- Corrections d'affichage : chiffres du tableau de bord alignés, page Mon compte. + +Passer `sql/migration-1.7-vers-1.8.sql`, puis remplacer `public/`, `src/`, `vues/`, `lang/`, `aide/`, `cron/`, `sql/` et `claude/`. Les disponibilités déjà saisies sont classées, leur texte d'origine reste en note. À la connexion suivante, chaque compte active la double authentification : prévenir les utilisateurs qu'il leur faut leur téléphone. Garder `config/cle-secrets.php` avec `config/config.php` dans les sauvegardes. + +## Utilisation + +1. Menu Importer. Deux voies : + - Analyse automatique : charger le CV. Vivier l'envoie à l'API Claude, qui renvoie le profil et le rapport. + - Fichiers déjà analysés : charger le JSON, le CV d'origine et le rapport produits dans un projet Claude. +2. Relire l'écran de contrôle, puis enregistrer. Le profil arrive dans la colonne Reçu. +3. Dans le Kanban, glisser les cartes d'une colonne à l'autre. Le passage en Non retenu demande un motif. Sur écran tactile, maintenir l'appui pour saisir une carte. +4. Sur une fiche : télécharger le CV d'origine, exporter le CV IT Club, commenter, changer le statut, noter les entretiens et les relances. + +## États et archivage + +Six états : Reçu, En cours, Entretiens en cours, Validé, Embauché, puis Non retenu en dernière colonne. + +- En cours : le profil est à l'étude, avant tout entretien. +- Entretiens en cours : un seul état, quel que soit le nombre d'entretiens. Chaque entretien se note sur la fiche. +- Archiver (carte Gestion de la fiche) : le profil sort du Kanban, du tableau de bord, du suivi et de la recherche courante. Ses données, ses documents et son échéance de conservation restent. Il se retrouve dans Profils, filtre Archivés, et se désarchive d'un bouton. +- Un profil archivé garde son statut. Ce statut ne change pas tant que le profil est archivé. + +## Suivi des candidats + +Entretiens, sur la fiche : + +- Date, heure facultative, type (téléphone, visioconférence, sur place, technique, client), personne qui mène l'entretien, besoin client concerné. +- Après l'entretien : avis (favorable, réservé, défavorable) et compte rendu. +- Au premier entretien d'un profil Reçu ou En cours, une case cochée fait passer le profil en « Entretiens en cours ». +- Le prochain entretien s'affiche sur la carte du Kanban. + +Relances, sur la fiche : une chose à faire et une date. Une relance arrivée à échéance s'affiche sur la carte du Kanban et dans la pastille du menu Suivi. + +Menu Suivi : les relances à faire, les entretiens des 30 prochains jours, les entretiens passés sans avis. + +## Besoins clients et rapprochement + +Menu Besoins. Un besoin décrit une mission : client, intitulé, technologies, séniorité minimale, langues, lieu, télétravail, début, budget journalier. + +- La fiche d'un besoin ouvert liste les profils du vivier qui y répondent, les mieux notés d'abord. « Proposer » ajoute un profil au besoin. +- Un profil proposé suit quatre états : à présenter, présenté au client, retenu par le client, écarté. Une note libre l'accompagne. +- Un profil se propose aussi depuis sa fiche, carte Besoins clients. +- Statut du besoin : ouvert, pourvu, fermé. Seul un besoin ouvert affiche le rapprochement. + +Note de rapprochement sur 100, calculée par `noter_rapprochement()` dans `src/besoins.php` : + +| Critère | Points | Règle | +|---|---|---| +| Technologies | 55 | Part des technologies demandées présentes sur la fiche. Une technologie présente vaut 60 % de sa part, 100 % à partir de 5 ans de pratique. | +| Intitulé | 15 | Part des mots de l'intitulé présents sur la fiche. | +| Séniorité | 10 | 10 si le niveau demandé est atteint, 4 un niveau en dessous, 0 sinon. | +| Langues | 10 | Part des langues demandées présentes sur la fiche. | +| Disponibilité | 5 | 5 si le profil est disponible avant le début, 0 après. | +| Tarif | 5 | 5 si le TJM souhaité tient dans le budget, 2 jusqu'à 10 % au-dessus, 0 au-delà. | + +Un critère absent du besoin donne tous ses points. Une donnée absente de la fiche (date de disponibilité, TJM) donne la moitié. Les profils archivés ou non retenus sont exclus. Un profil sans aucune technologie demandée ni aucun mot de l'intitulé n'est pas listé. + +## Données commerciales + +Sur la fiche, bouton Modifier : date de disponibilité, TJM souhaité, salaire annuel brut souhaité, mobilité, télétravail. Ces données servent au rapprochement et à l'export CSV. Elles ne figurent sur aucun CV exporté. + +## Données personnelles + +Carte Données personnelles de la fiche : + +- Accord du candidat : la date à laquelle il a accepté la conservation de son CV, et la façon dont il l'a dit. L'accord se modifie et se retire. La page Conservation l'affiche. +- Exporter les données du candidat : un zip avec `donnees.json` (fiche, commentaires, entretiens, relances, historique), les documents et la photo. À remettre à un candidat qui demande l'accès à ses données. + +## Import en lot + +Menu Importer, bouton « Importer plusieurs CV ». 20 CV au plus dans le lot. + +- CV à analyser : sélectionner plusieurs fichiers. Vivier les envoie à l'API Claude un par un, une requête par CV. La page doit rester ouverte pendant les analyses. Si elle est fermée, les analyses reprennent à sa réouverture. +- Profils déjà analysés : sélectionner ensemble les JSON, les CV et les rapports. Chaque JSON est associé au CV que nomme son champ `source.fichier_origine`. Un rapport `.md` ou `.txt` est associé au profil dont son nom reprend le nom du JSON ou le nom du candidat. +- Chaque ligne du lot s'ouvre en relecture. « Enregistrer les profils prêts » enregistre d'un coup ceux qui ne ressemblent à aucun profil existant. +- Un CV laissé dans le lot est retiré après 7 jours, avec ses fichiers temporaires. + +## Doublons + +À l'import, Vivier compare le profil aux fiches existantes, archivées comprises. + +- Même adresse email : l'enregistrement est refusé. La fiche existante se met à jour par sa carte Gestion. +- Même téléphone (indicatif, espaces et ponctuation ignorés) ou même prénom et même nom (casse, accents et ordre ignorés) : avertissement. Une case à cocher confirme qu'il s'agit d'une autre personne. + +## Analyse automatique + +À activer dans `config/config.php`, clé `claude` : renseigner `cle_api`. Sans clé, seul l'import par fichiers est proposé. + +- La clé se crée dans la console Anthropic. L'usage de l'API est facturé à part d'un abonnement Claude. +- Formats lus : PDF (envoyé tel quel), DOCX, ODT, MD, TXT (texte extrait par Vivier). Un DOC ou un RTF doit être converti en PDF. +- Les consignes envoyées sont celles du dossier `claude/`. Les modifier change les deux voies d'analyse. +- Le CV est transmis à Anthropic pour l'analyse. Le mentionner dans l'information donnée aux candidats. +- Délai maximal : 240 secondes, réglable. Si l'hébergement coupe avant, passer par les fichiers déjà analysés. + +## Liste, export et tableau de bord + +- Profils : la liste complète, filtrable. « Exporter en CSV » télécharge les profils affichés (séparateur point-virgule, lisible par Excel). +- Tableau de bord : profils par statut, temps moyen passé à chaque étape, séniorité, technologies les plus présentes. + +## Recherche + +Le champ de l'en-tête et le menu Profils interrogent toutes les fiches. + +- Tous les mots saisis doivent figurer sur la fiche. Le score classe les résultats. +- Accents, majuscules et pluriels sont ignorés. Un mot de trois lettres ou plus trouve aussi les mots qui commencent par lui. +- « senior » filtre sur Confirmé et Expert. « junior », « confirmé », « expert » filtrent de même. +- Une expression entre guillemets est cherchée telle quelle : `"gestion documentaire"`. +- Les symboles des technologies sont gardés : C#, C++, .NET, Node.js, CI/CD. +- Filtres : statut, séniorité, langue, profils archivés (masqués, inclus, seulement). + +Poids d'un mot selon sa place : nom 8, titre et métier 6, spécialité et technologie 5 (jusqu'à 10 selon les années de pratique), secteur et mot-clé 4, poste et langue 3, synthèse, employeur et formation 2, description 1. Un mot exact compte double par rapport à un début de mot. + +Les mots-clés et les métadonnées viennent du JSON (format 1.1) : `mots_cles`, et `metadonnees` avec les métiers, spécialités, secteurs, niveau d'études et encadrement. Un recruteur peut en ajouter ou en retirer sur la fiche. Les ajouts manuels survivent à une mise à jour de l'analyse. + +Les synonymes se règlent dans la table `synonymes_recherche` (phpMyAdmin) : `seniorite` transforme un mot en filtre, `texte` ajoute une variante. Réindexer n'est pas nécessaire après un changement de synonyme. + +## Gestion des profils + +- Modifier : identité, coordonnées, titre, disponibilité, synthèse. Chaque champ modifié est tracé avec l'ancienne et la nouvelle valeur. +- Mettre à jour depuis une nouvelle analyse : remplace expériences, technologies, langues et mots-clés. Garde le statut, les commentaires, les documents et l'historique. +- Supprimer : réservé au gestionnaire des comptes. Le profil part à la corbeille et disparaît de toute l'application. Le gestionnaire le restaure ou l'efface depuis Gestion, Corbeille. +- Effacement : 30 jours après la suppression (`CORBEILLE_JOURS`), par la tâche planifiée, à la connexion d'un gestionnaire ou à l'ouverture de la corbeille. Partent la fiche, les documents, la photo, les commentaires, les entretiens, les relances, les mots-clés et l'historique. Le journal garde une ligne avec la référence du profil, sans nom. +- Conservation : liste les profils échus ou à 60 jours de l'échéance, à prolonger ou à supprimer. +- Rapport : un rapport .md ou .txt se lit dans Vivier par le lien Lire. + +## Photos des candidats + +La photo sert à reconnaître un candidat dans Vivier : fiche, cartes du Kanban, résultats de recherche. + +- À l'import, Vivier cherche la photo dans le CV (Word, OpenDocument, PDF) et la montre sur l'écran de relecture. Une case permet de ne pas la garder. +- Sur la fiche, la carte Photo permet d'en charger une, de la remplacer ou de la retirer. +- La photo est réenregistrée en JPEG de 480 px au plus, dans `stockage/photos/`. Elle part avec le profil à la suppression. +- Elle ne figure jamais sur les CV exportés et n'est jamais décrite ni analysée par Claude. +- Vivier choisit l'image du CV qui ressemble à un portrait : format vertical ou carré, sans grande plage unie. Un logo est écarté. Un portrait détouré sur un large fond blanc peut l'être aussi : il se charge alors à la main. +- La recherche de photo demande l'extension GD de PHP. Sans elle, seul le chargement manuel est refusé et l'import continue. + +## Langues + +Interface : français et anglais. Chaque compte garde sa langue. Le choix se fait dans le menu du compte, en haut à droite, ou sur la page de connexion. + +Profils : chaque profil s'affiche dans la langue de l'interface. La traduction se fait à l'import, en arrière-plan (table `traductions_a_faire`, traitée une à une par le script des pages ouvertes), et de nouveau après chaque modification. Commentaires, comptes rendus, besoins et permis de travail ne sont pas traduits. + +CV générés : français, anglais, allemand, au choix dans l'aperçu du CV. + +- Les intitulés du CV suivent la langue choisie. +- Le contenu (titre, synthèse, postes, descriptions, diplômes) reste dans la langue du CV d'origine tant qu'il n'est pas traduit. L'aperçu le signale. +- Traduire le contenu : bouton « Traduire le contenu » si l'analyse automatique est configurée, sinon « Traduire par fichier » (télécharger le fichier, le déposer dans le projet Claude d'analyse, recharger le fichier traduit). +- La traduction est gardée. Si la fiche change ensuite, elle est signalée périmée et le contenu d'origine revient. +- Les noms de technologies, d'entreprises et de clients ne sont pas traduits. + +Ajouter une langue d'interface : copier `lang/en.php` en `lang/xx.php`, traduire les valeurs, puis ajouter la langue à `LANGUES_INTERFACE` dans `src/i18n.php`. Une clé non traduite s'affiche en français. Pour une langue de CV, quelques dizaines de libellés suffisent (voir `lang/de.php`) et la langue s'ajoute à `LANGUES_CV`. + +Le journal enregistre les actions en français et les affiche dans la langue de l'interface. Les données des profils (commentaires, synthèse, mots-clés) restent telles qu'elles ont été saisies. + +## Sauvegarde + +Menu Gestion, Sauvegarde, réservé au gestionnaire des comptes. + +Chaque semaine, la tâche planifiée de l'hébergement lance `cron/sauvegarde.php`. Le script exporte toute la base dans un fichier SQL, le compresse en zip, garde une copie dans `stockage/sauvegardes/` et l'envoie par email. + +Programmer la tâche chez OVH : + +1. Espace client, hébergement, onglet « Plus », « Planificateur de tâches », « Ajouter une planification ». +2. Commande : `vivier/cron/sauvegarde.php`. Langage : PHP 8.3. +3. Fréquence : jour de la semaine dimanche, heure 6. OVH ne règle pas les minutes : la tâche part entre 6 h 00 et 6 h 59. +4. Adresse du rapport : facultative. OVH y envoie ce que le script affiche. + +Réglages, dans `config/config.php`, clé `sauvegarde` : + +- `destinataires` : les adresses qui reçoivent le fichier. +- `expediteur` : l'adresse d'expédition. Le domaine de cette adresse doit autoriser le serveur d'OVH dans son enregistrement SPF, sinon l'email risque le dossier des indésirables. +- `semaines_conservees` : 0 garde toutes les copies sur le serveur, un nombre supprime les copies plus anciennes. +- `taille_max_email` : au-delà, l'export est refait sans l'index de recherche. S'il reste trop lourd, l'email part sans pièce jointe et le fichier se télécharge dans Vivier. + +Avant de compter sur la sauvegarde : page Sauvegarde, « Envoyer un email d'essai », puis « Sauvegarder maintenant » avec l'envoi coché. Vérifier les deux boîtes, courriers indésirables compris. + +Le fichier n'est pas chiffré. Il contient les données des candidats, les empreintes des mots de passe et les clés de double authentification chiffrées. Sa protection est celle des boîtes qui le reçoivent. Les copies gardent les profils supprimés depuis : supprimer les anciens emails de sauvegarde pour respecter la durée de conservation. + +En cas d'échec, un email d'alerte part aux mêmes adresses. Le tableau de bord du gestionnaire alerte aussi quand aucune sauvegarde n'a réussi depuis plus de 8 jours : ce second signal couvre le cas où la tâche ne démarre plus ou l'email ne part plus. + +Fichiers : les CV, rapports, pièces jointes et photos sont trop lourds pour un email. Le bouton « Télécharger l'archive des fichiers » en fait un zip, avec un `index.csv` qui relie chaque fichier à son profil. Le télécharger le même jour que la sauvegarde de la base. + +Restaurer : + +1. phpMyAdmin, base de Vivier, onglet Importer : charger le fichier `.sql` extrait du zip. Il remplace toutes les tables. +2. Par FTP, copier les dossiers `docs/` et `photos/` de l'archive des fichiers dans `stockage/`. +3. Si le fichier SQL annonce en tête que l'index de recherche n'est pas inclus : Vivier, Comptes, « Réindexer tous les profils ». +4. Garder `config/cle-secrets.php` (ou la clé `cle_secrets`) : sans lui, chacun se connecte avec un code de secours et réactive sa double authentification. +5. Se connecter. Les comptes, les mots de passe et la double authentification sont ceux du jour de la sauvegarde. + +## Aide intégrée + +Le point d'interrogation de l'en-tête ouvre l'aide. + +- Guide d'utilisation et guide Sécurité : pour tous les comptes. Guide d'administration : pour le gestionnaire des comptes. +- Chaque guide existe en français et en anglais. Il s'ouvre dans la langue de l'interface, et les boutons FR et EN changent la langue du guide seul. +- « Imprimer ou enregistrer en PDF » sort le guide affiché. +- Les textes sont les fichiers Markdown du dossier `aide/` : `guide`, `securite` et `administration`, en `.fr.md` et `.en.md`. Modifier un fichier modifie l'aide, sans autre réglage. +- Le guide Sécurité se lit aussi sans être connecté, depuis la page de connexion. +- Mise en forme lue : titres (`#` pour un chapitre, `##` pour une section), listes, tableaux, gras, code. Garder les mêmes chapitres dans les deux langues. + +## Sécurité + +Le détail est dans l'aide : guide « Sécurité » pour tous, chapitre Sécurité du guide d'administration. Réglages dans `src/bootstrap.php` et `src/securite.php`. + +- Double authentification obligatoire : codes à 6 chiffres (RFC 6238), dix codes de secours hachés, clé chiffrée en AES-256-GCM avec une clé gardée hors de la base (`config/cle-secrets.php` ou `cle_secrets`). +- Accès sur lien d'invitation, valable 48 heures et une seule fois. La création directe par le gestionnaire reste possible, avec un mot de passe provisoire à changer. +- Blocage après 5 échecs sur un identifiant depuis une adresse : 15, puis 30 et 60 minutes. Blocage d'une adresse après 20 échecs en une heure. Déblocage manuel dans Comptes. +- HTTPS obligatoire : redirection et en-tête HSTS d'un an (`.htaccess` et PHP). +- Session fermée après 2 heures sans activité, 12 heures au plus. Changer de mot de passe ferme les autres sessions. Bouton « Déconnecter mes autres sessions ». +- Mots de passe de 12 caractères au moins, refusés s'ils figurent dans une fuite connue (Pwned Passwords, seuls 5 caractères de l'empreinte partent), hachés en Argon2id. +- Journal des connexions de 12 mois. Email d'alerte quand un gestionnaire se connecte depuis un réseau jamais vu. +- Export CSV, journal, conservation, corbeille, suppression : gestionnaire seulement. Mot de passe redemandé pour confier la gestion, changer le mot de passe d'un autre, remettre à zéro une double authentification, télécharger l'archive des fichiers. +- Journal des erreurs imprévues avec une référence, sans donnée de candidat. +- Site exclu des moteurs de recherche : en-tête `X-Robots-Tag` (PHP et `.htaccess`) et balise `robots`. Pas de `robots.txt`. + +## Interface + +- Mise en page adaptée du téléphone au grand écran. Le menu se replie sous 1180 px, le Kanban défile colonne par colonne sous 900 px. +- En-tête sur une ligne, fixe en haut de page : cinq rubriques, le sous-menu Gestion réservé au gestionnaire (Conservation, Journal, Connexions, Comptes, Corbeille, Sauvegarde, Erreurs), la recherche, le bouton Importer et le menu du compte (mon compte, langue, déconnexion). +- Fiche profil : un sommaire reste collé sous l'en-tête et mène à chaque section. L'historique montre les 10 dernières actions, le reste se déplie. +- Les pages s'enchaînent en fondu sur les navigateurs qui gèrent les transitions entre pages. Les animations sont coupées si le système demande de réduire les mouvements. +- Polices Bodoni Moda et Instrument Sans, hébergées dans `public/assets/polices/` (licence SIL Open Font License). Aucun appel à un service externe. + +## CV au format IT Club + +Sur une fiche, les boutons CV IT Club et CV anonymisé ouvrent l'aperçu A4. Deux exports partent de cet aperçu. + +- Télécharger en Word : fichier .docx modifiable, généré par `src/ExportWord.php`. +- Enregistrer en PDF : impression du navigateur. Choisir « Enregistrer au format PDF » et décocher « En-têtes et pieds de page ». + +Les deux exports lisent le même contenu, préparé par `donnees_cv()` dans `src/depot.php`. + +- Aucune coordonnée personnelle. Le contact passe par IT Club. +- La version anonymisée remplace le nom par une référence (ITC-0001) et masque la localisation. +- Les noms d'employeurs et de clients restent visibles dans les deux versions. +- Les mentions de la société se règlent dans `config/config.php`, clé `societe`. +- Chaque export est tracé dans l'historique du profil. + +## CV Europass + +Sur une fiche, CV Europass, nominatif ou anonymisé. Mise en page du CV Europass d'IT Club : bandeau bleu, logo Europass officiel en version négative, rubriques européennes. Aperçu HTML enregistrable en PDF (`vues/europass.php`, `public/assets/europass.css`, police Open Sans sous licence SIL OFL) et export Word (`src/ExportEuropass.php`). Contenu préparé par `donnees_europass()` dans `src/europass.php`. + +- Ni date de naissance, ni nationalité, ni permis de travail. +- Coordonnées affichées par défaut sur la version nominative, retirables. Photo sur demande. +- Version anonymisée : référence à la place du nom, ni photo ni coordonnées. +- Grille européenne des langues quand le CV la donne, sinon une ligne par langue. + +## Comptes + +- Un seul niveau d'accès aux profils : chaque compte lit et modifie tous les profils. +- Connexion par identifiant, mot de passe et code de double authentification, sans adresse email. +- Le premier compte créé est gestionnaire : il invite, crée et modifie les comptes, désactive un compte, remet à zéro une double authentification, lève un blocage. Il peut confier ce rôle à un autre compte. +- Chacun gère son mot de passe, ses codes de secours, son application et ses sessions dans Mon compte. +- Les demandes de compte faites avant la 1.8 restent visibles dans Comptes, à accepter ou refuser. + +Pour rétablir un accès en lecture seule plus tard : la fonction `peut_ecrire()` de `src/bootstrap.php` est le seul point à modifier. + +## Règles de calcul + +- Séniorité recalculée à l'import à partir des dates. Stages exclus, périodes qui se chevauchent fusionnées. +- Un poste terminé compte son dernier mois. Un poste en cours compte jusqu'au dernier mois complet. +- Une date sans mois vaut janvier. +- Junior sous 4 ans, intermédiaire de 4 à moins de 8, confirmé de 8 à moins de 12, expert à partir de 12. + +## Tests + + php tests/test_import.php + php tests/test_recherche.php + php tests/test_photo.php + php tests/test_securite.php + php tests/test_i18n.php + php tests/test_suivi.php + php tests/test_aide.php diff --git a/aide/.htaccess b/aide/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/aide/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/aide/administration.en.md b/aide/administration.en.md new file mode 100644 index 0000000..e76f0e9 --- /dev/null +++ b/aide/administration.en.md @@ -0,0 +1,375 @@ +# The account manager's role + +The account manager has the same rights as everyone else on profiles. In addition, they have the **Manage** menu: + +- **Accounts**: invite, create, edit, disable an account, reset a two-step verification, lift a block +- **Sign-ins**: the log of every sign-in attempt +- **Activity log**: the actions on all profiles +- **Retention** and **Trash**: the retention period and the deletion of profiles +- **Backup**: status, run, download +- **Errors**: unexpected errors, with their reference + +They also have the CSV export of the profile list, the backup alert on their dashboard and this administration guide. + +The first account created is the account manager. It can grant this role to other accounts. Keep at least two account managers: if one loses their phone and backup codes, the other resets their two-step verification. + +The **Security** guide, readable by everyone, explains the protections to users. This page explains how to manage them. + +# Accounts + +**Manage** menu, **Accounts**. + +## Inviting someone + +The recommended way: nobody but the person knows their password. + +1. **Invite someone** block: type their name, optional, then **Create an invitation link**. +2. Copy the link shown and send it to the person, by email or internal chat. It is shown only once. +3. The person opens the link, chooses a username and password, then sets up two-step verification. The account is active right away. + +The link works once and expires after 48 hours. **Open links** lists the links not used yet. **Cancel the link** makes it unusable. Link lost or expired: create another one. + +The invitation link uses the address of the page you have open. To fix it, fill in the `adresse` key of `config/config.php`. + +## Creating an account + +You can also create the account yourself. + +1. Type the person's name. +2. Choose their username: 3 to 40 characters, unaccented letters, digits, dot, hyphen. +3. Choose a temporary password: at least 12 characters. +4. Click **Create account**, then give the username and password to the person, in person or through a different channel from the username. + +At first sign-in, the person chooses their own password, then sets up two-step verification. + +## Reading the account list + +Each account shows its username, role, two-step verification status and last sign-in. **Two-step verification to set up** flags an account that has not done it yet. **Password to change** flags a temporary password not yet replaced. + +## Editing an account + +Under each account, **Change the name, username or password**. Leave the password empty to keep it. + +A new password is temporary: the person changes it at next sign-in, and their open sessions close. To give one, type your own password again. + +- **Disable account**: the person can no longer sign in and their sessions close at once. Their actions stay in the activity log. **Enable account** restores access. +- **Grant account management**: the account becomes an account manager. **Remove account management** does the opposite. Type your password to confirm. +- **Sign-ins** opens the sign-in log of this account. + +You cannot disable your own account, nor remove your own role. An account cannot be deleted: the activity log keeps its actions. + +## Forgotten password + +Give a temporary password through **Change the name, username or password**. The person replaces it at sign-in. + +## Lost phone + +The person first signs in with a backup code, then sets up another app from **My account**. + +No backup codes left: + +1. Check that it really is the person: by phone, in person. A request by email is not enough. +2. Under their account, **Reset two-step verification**, then type your password. +3. The person sets up a new app at next sign-in. Their open sessions are closed. + +## Blocked sign-ins + +The **Blocked sign-ins** block appears while a block is active: + +- a username blocked from an address, after 5 failures (15 minutes, then 30 and 60) +- an address blocked for every username, after 20 failures in an hour + +**Unblock** lifts the block at once. Do it only if you know who is trying, for example a colleague who mistyped their password. A series of failures from an unknown address: leave the block and check the sign-in log. + +## Requests made before version 1.8 + +The public account request page no longer exists. Requests still waiting show in **Account requests to approve**. **Approve** opens access: the person sets up two-step verification when signing in. **Decline** erases the request. + +# Sign-in log + +**Manage** menu, **Sign-ins**. + +Every sign-in attempt is listed: date, account, username typed, IP address, network, browser, result. Filter by account or keep failures only. The log keeps 12 months. The scheduled task and an account manager's sign-in erase older rows. + +| Result | Meaning | +|---|---| +| Signed in | Password and code correct | +| Wrong password | Known or unknown username, wrong password | +| Wrong code | Correct password, wrong two-step code | +| Attempt blocked | Attempt refused during a block | +| Account disabled | Attempt on a disabled account | + +**The email alert.** When an account manager signs in from a network never seen for that account, Vivier sends an email to the `comptes.notifier` addresses, or to the sender address. The network is the address range (the first three numbers of an IPv4 address). An account's first sign-in triggers no alert. + +You don't recognise a sign-in: change the account's password, reset its two-step verification, then close its sessions by disabling and enabling it. + +# Errors + +**Manage** menu, **Errors**. + +When a page fails, the user reads "Something went wrong" and a six-character reference. The **Errors** page lists the last 300, with the reference, date, page, cause and code location. No candidate data is stored there. Pass the row to whoever maintains Vivier. **Clear the log** empties the list. + +# Deleting profiles and the trash + +Only the account manager deletes a profile: the **Delete profile** button at the bottom of the profile page, or **Delete** on the Retention page. Other accounts do not see these buttons. + +## What deleting does + +The profile goes to the trash. It disappears at once from the board, the profiles, search, client requests, follow-up, the dashboard, the CSV export and the Retention page. Its page and its documents no longer open. In the activity log, its name is replaced by its reference. + +## The Trash page + +**Manage** menu, **Trash**. Each line gives the profile, the deletion date, the account that deleted it and the erasure date. + +- **Restore** puts the profile back, with all its data. It returns to the bottom of its column. +- **Erase now** erases the profile without waiting. This cannot be undone. + +## Erasure after 30 days + +After 30 days, Vivier erases the profile, the original CV, the photo, the attachments, the comments, the interviews, the follow-ups, the keywords and the history. The activity log keeps one line: the profile's reference, the account that deleted it and the date it went to the trash. No name. + +Erasure happens at three moments: the scheduled task on Sunday, an account manager signing in, the Trash page being opened. A profile can therefore stay a few days longer if nobody signs in. + +The period is set by `CORBEILLE_JOURS`, at the top of `src/bootstrap.php`. + +## Good to know + +- While a profile is in the trash, its email address stays taken. Importing the same candidate is refused: restore the profile or erase it. +- Backups already made keep the profiles deleted since. A backup made during the 30 days contains the profiles in the trash. + +# Backup + +**Manage** menu, **Backup**. + +## What is backed up + +| Item | Where | How | +|---|---|---| +| Database | Email and copy on the server | Every week, by the scheduled task | +| Files: CVs, reports, attachments, photos | Download | By hand, **Download the files archive** button, after typing your password | + +The database holds the profiles, comments, interviews, follow-ups, requests, activity log and accounts. The files are too heavy for an email. + +## The weekly backup + +The hosting's scheduled task runs the `cron/sauvegarde.php` script. The script: + +1. exports the whole database to an SQL file +2. compresses it into a zip +3. keeps a copy in `stockage/sauvegardes/` +4. sends the zip to the addresses set in `config/config.php` + +The file is not encrypted. It holds candidates' data, password fingerprints and the encrypted two-step verification keys. Its protection is that of the inboxes that receive it: strong password and two-factor authentication. + +## Scheduling the task at OVH + +1. Control panel, hosting, **More** tab, **Scheduled tasks**, **Add a schedule**. +2. Command: `vivier/cron/sauvegarde.php`. Language: PHP 8.3. +3. Frequency: the day and hour you want. OVH does not set minutes: a task planned for 6 am starts between 6:00 and 6:59. +4. Report address: optional. OVH sends what the script prints to it. + +## Checking that it works + +1. Backup page, **Send a test email, without attachment**. Check the inboxes, spam folders included. +2. Tick **Also send the backup by email**, then **Back up now**. +3. Open the zip you receive: it contains a `.sql` file. +4. After the scheduled task has run once, check the "Scheduled task" line in the history. + +If the email lands in spam, create a rule for the sender. The domain of the sender address must authorise the OVH server in its SPF record, and publish a single SPF record. + +## Alerts + +| Signal | Meaning | +|---|---| +| "ÉCHEC de la sauvegarde" email | The backup failed, or it was made but could not be sent. The email is written in French | +| Red banner on the dashboard | No successful backup for more than 8 days, or a problem with the last one | + +The banner covers the case where the task no longer starts or the email no longer goes out: in that case no alert email arrives. + +## Copies on the server + +The history on the page lists every backup. **Download** fetches the zip. **Delete** removes the copy from the server, not the emails received. + +The `semaines_conservees` setting sets how long: 0 keeps every copy, a number deletes older copies. + +A copy still holds profiles deleted since. To respect the data retention period, delete old backup copies and emails. + +## If the file gets too heavy + +Above `taille_max_email`, Vivier runs the export again without the search index, which can be rebuilt. If it is still too heavy, the email goes out without attachment and the file is downloaded from Vivier. + +## Restoring + +1. phpMyAdmin, Vivier's database, **Import** tab: load the `.sql` file from the zip. It replaces every table. +2. By FTP, copy the `docs/` and `photos/` folders from the files archive into `stockage/`. +3. If the SQL file says at the top that the search index is not included: Manage menu, Accounts, **Reindex all profiles**. +4. Keep `config/cle-secrets.php`, or the `cle_secrets` key: it decrypts the two-step verification keys in the backup. +5. Sign in. Accounts, passwords and two-step verification are those of the day of the backup. + +Download the files archive on the same day as the database backup: the database only finds the files that existed that day. + +# Installing on OVH hosting + +Vivier needs PHP 8.3 and a MySQL or MariaDB database. No framework, no dependency to install. + +1. Control panel, **Databases** tab: create a MySQL database. Note the host, name, user and password. +2. phpMyAdmin, **Import** tab: load `sql/schema.sql`. +3. Upload the whole `vivier/` folder by FTP, next to `www/`. +4. **Multisite** tab: add the subdomain with `vivier/public` as root folder. Tick SSL. +5. Copy `config/config.exemple.php` to `config/config.php` and fill in the database. +6. Open the site address with `https://`. The first-run page creates your account, the account manager, then has you set up two-step verification. +7. Schedule the weekly backup. + +The root folder must be `vivier/public`. The other folders then cannot be reached by an address. `.htaccess` files block them too, as a second protection. + +| Folder | Purpose | +|---|---| +| `public/` | Web root: `index.php`, styles, scripts, fonts | +| `src/` | PHP code | +| `vues/` | Screens | +| `lang/` | Translations of the interface and the CVs | +| `aide/` | The texts of this help | +| `config/` | Configuration | +| `stockage/` | CVs, documents, photos, backups | +| `cron/` | Weekly backup script | +| `sql/` | Database creation and migrations | +| `claude/` | Instructions, schema and example for the Claude analysis project | +| `tests/` | Tests | + +# Updating + +1. Export the database in phpMyAdmin, **Export** tab. +2. Run, in order, the `sql/migration-*.sql` files that have not been run yet. + Migration 1.5 adds the trash and account requests. It also creates the account manager `ypilla`, or updates its password and role if it exists. + Migration 1.8 adds two-step verification, invitations, the sign-in log, nationality, work permit, classified availability and the Europass CV fields. It classifies the availability already entered and keeps the original text as a note. +3. Replace by FTP the `public/`, `src/`, `vues/`, `lang/`, `aide/`, `cron/`, `sql/`, `claude/` and `tests/` folders. Keep `config/config.php` and `stockage/`. +4. Copy the new keys of `config/config.exemple.php` into `config/config.php`. In 1.8: `adresse`, `cle_secrets`, `securite`. +5. The `config/` folder must be writable at first run: Vivier creates `cle-secrets.php` there. Otherwise, fill in `cle_secrets` yourself. +6. In Vivier: Manage menu, Accounts, **Reindex all profiles**, then **Translate all profiles**. +7. In the Claude analysis project: replace the instructions, the schema and the example with those of the `claude/` folder. + +After the 1.8 update, every account sets up two-step verification at its next sign-in. Tell users: they need their phone. + +# Configuration + +Everything is set in `config/config.php`. This file must never be published. + +| Key | Purpose | +|---|---| +| `nom_app` | The name shown in the header | +| `fuseau` | The time zone of dates | +| `db` | The host, port, name, user and password of the database | +| `societe` | IT Club's details on exported CVs | +| `claude` | The API key, the model, the maximum response length, the time limit | +| `sauvegarde` | The recipients, the sender, how long copies are kept, the maximum email size | +| `stockage` | The files folder, outside the web root | +| `taille_max` | The maximum size of an uploaded file, in bytes | +| `conservation_mois` | The retention period of a profile, in months | +| `comptes` | `notifier`: the addresses that receive the alert when an account manager signs in from a new network | +| `adresse` | Vivier's public address, used in invitation links. Optional | +| `cle_secrets` | The encryption key of two-step verification keys. Empty: Vivier creates `config/cle-secrets.php` | +| `securite` | `mots_de_passe_fuites`: `false` turns off the check for passwords found in breaches | +| `https` | `true` in production: the session cookie only travels over HTTPS | + +# Automatic analysis + +To turn it on, fill in `cle_api` in the `claude` key of `config/config.php`. Without a key, only the import of files already analysed is offered. + +- The key is created in the Anthropic console. API usage is billed separately from a Claude subscription. +- Formats read: PDF, DOCX, ODT, MD, TXT. +- The instructions sent to Claude are those of the `claude/` folder. Changing them changes both analysis routes. +- The CV is sent to Anthropic for the analysis. +- Time limit: 240 seconds, adjustable. If the hosting cuts the request earlier, use the files already analysed. + +# Profile translation + +Each profile shows in the interface language. A recruiter working in English reads the titles, summary, positions and points of attention in English. + +- At import, Vivier plans the profile's translation into each interface language. +- Translations run in the background, one at a time, while a Vivier page stays open for a user. +- An edited profile is queued again. Meanwhile, the original text shows with a banner. +- Comments, interview reports, client requests and work permits are not translated: they stay in their author's language. +- Names of technologies, companies, clients and projects stay as they are. + +**Manage** menu, **Accounts**, **Maintenance** block: + +- the number of translations to do +- the translations dropped after 3 attempts, with their cause +- **Translate all profiles** queues every profile without an up-to-date translation, and retries dropped ones + +Without automatic analysis, translations go through a file, from the IT Club CV preview, as before. + +# Search: index and synonyms + +Vivier stores each profile in a search index. The index is updated at every import or edit. + +**Reindex all profiles** (Manage menu, Accounts) rebuilds the index. Run it after a Vivier update or a restore without index. + +Synonyms are set in the `synonymes_recherche` table, with phpMyAdmin: + +| Type | Effect | Example | +|---|---|---| +| `seniorite` | The word becomes a seniority filter | `senior` to `confirme` and `expert` | +| `texte` | The word gains a variant | `dev` to `développeur` | + +A synonym change takes effect at once, without reindexing. + +# Security + +The **Security** guide, in the help, describes the protections for users. Here is what falls to you. + +| Setting | Value | Where | +|---|---|---| +| Two-step verification | Required for everyone, 6-digit codes (RFC 6238), 10 backup codes | `src/securite.php` | +| Password | At least 12 characters, at most 200, rejected if found in a known breach | `LONGUEUR_MOT_DE_PASSE`, `LONGUEUR_MAX_MOT_DE_PASSE` | +| Password fingerprint | Argon2id, recomputed at sign-in for older accounts | `hacher_mot_de_passe()` | +| Username block | 5 failures: 15 min, then 30 and 60 min | `ECHECS_SERIE`, `BLOCAGES_MINUTES` | +| Address block | 20 failures in an hour | `ECHECS_IP_HEURE` | +| Session | 2 h of inactivity, 12 h at most | `DUREE_INACTIVITE`, `DUREE_SESSION_MAX` | +| Invitation link | 48 h, single use | `INVITATION_HEURES` | +| Sign-in log | 12 months | `CONNEXIONS_MOIS` | + +The constants sit at the top of `src/bootstrap.php` and `src/securite.php`. + +Other protections: + +- HTTPS required when `https` is `true`: `http://` addresses are redirected and a one-year HSTS header is sent. Set `https` to `false` only for a trial on a local machine. +- Two-step verification keys are encrypted (AES-256-GCM). The encryption key is in `config/cle-secrets.php`, created when first needed, or in the `cle_secrets` key of `config/config.php`. Back it up with `config.php`: without it, everyone has to sign in with a backup code and set up two-step verification again. +- Passwords are checked against the Pwned Passwords service: only the first 5 characters of the SHA-1 fingerprint leave the server. If the service does not answer, the password is accepted. `securite.mots_de_passe_fuites` set to `false` turns this check off. +- Your password is asked again to grant or remove account management, give a password to another account, reset a two-step verification and download the files archive. +- CSV export, activity log, retention, trash, profile deletion: account manager only. +- Site kept out of search engines: `X-Robots-Tag` header and `robots` tag on every page. +- Files stored outside the web root, under a random name. +- Every action is recorded in the activity log, with its author. +- No call to an external service, except the Claude API when automatic analysis is on, and Pwned Passwords. + +# Languages + +The interface exists in French and English. CVs are generated in French, English and German. + +Adding an interface language: + +1. Copy `lang/en.php` to `lang/xx.php` and translate the values. An untranslated key shows in French. +2. Add the language to `LANGUES_INTERFACE` in `src/i18n.php`. +3. Copy `aide/guide.en.md` and `aide/administration.en.md` with the language code, and translate them. Without these files, the help shows in French. + +For a CV language, a few dozen labels are enough: see `lang/de.php`. The language is added to `LANGUES_CV`. + +# Troubleshooting + +| Symptom | Likely cause | What to do | +|---|---|---| +| "Configuration absente" | `config/config.php` is missing | Copy `config/config.exemple.php` and fill it in | +| "Something went wrong" | Server-side error | Manage menu, **Errors**: look up the reference the user gives you | +| A user has neither their phone nor their codes | Two-step verification lost | Check who they are, then **Reset two-step verification** | +| "Too many failed attempts" | Block after failures | Wait, or **Unblock** in Accounts if you know who is trying | +| Two-step code always rejected | Phone clock off | Set the phone's time to automatic | +| Nobody can validate their code any more | `config/cle-secrets.php` lost or changed | Put the original file back. Otherwise, everyone signs in with a backup code and sets up two-step verification again | +| "Vivier only works over HTTPS" | Form sent over `http://` | Open the address with `https://`. Check the multisite SSL certificate | +| Profiles still in French in English | Translations not done | Accounts, **Translate all profiles**, then keep a page open | +| The automatic analysis exceeds the time limit | Hosting time limit | Use the files already analysed | +| The search finds nothing | Empty index | **Reindex all profiles** | +| No backup email | Task stopped, SPF, spam folder | Test email, then check the task in the control panel | +| A file upload fails | File above 10 MB | Shrink the file, or raise `taille_max` and the PHP limit | + +Tests run from the command line: `php tests/test_import.php`, then the same for `test_recherche.php`, `test_photo.php`, `test_i18n.php`, `test_suivi.php`, `test_aide.php` and `test_securite.php`. diff --git a/aide/administration.fr.md b/aide/administration.fr.md new file mode 100644 index 0000000..c4b71e6 --- /dev/null +++ b/aide/administration.fr.md @@ -0,0 +1,375 @@ +# Rôle du gestionnaire + +Le gestionnaire des comptes a les mêmes droits que les autres sur les profils. Il a en plus le menu **Gestion** : + +- **Comptes** : inviter, créer, modifier, désactiver un compte, remettre à zéro une double authentification, lever un blocage +- **Connexions** : le journal de chaque essai de connexion +- **Journal** : les actions sur tous les profils +- **Conservation** et **Corbeille** : la durée de conservation et la suppression des profils +- **Sauvegarde** : état, lancement, téléchargement +- **Erreurs** : les erreurs imprévues, avec leur référence + +Il a aussi l'export CSV de la liste des profils, l'alerte de sauvegarde sur son tableau de bord et ce guide d'administration. + +Le premier compte créé est gestionnaire. Il peut confier ce rôle à d'autres comptes. Gardez au moins deux gestionnaires : si l'un perd son téléphone et ses codes de secours, l'autre remet sa double authentification à zéro. + +Le guide **Sécurité**, lisible par tous, explique les protections aux utilisateurs. Cette page dit comment les gérer. + +# Comptes + +Menu **Gestion**, **Comptes**. + +## Inviter une personne + +La voie conseillée : personne d'autre que la personne ne connaît son mot de passe. + +1. Bloc **Inviter une personne** : saisissez son nom, facultatif, puis **Créer un lien d'invitation**. +2. Copiez le lien affiché et envoyez-le à la personne, par email ou messagerie interne. Il ne s'affiche qu'une fois. +3. La personne ouvre le lien, choisit son identifiant et son mot de passe, puis active la double authentification. Son compte est actif tout de suite. + +Le lien sert une seule fois et expire après 48 heures. **Liens en cours** liste les liens pas encore utilisés. **Annuler le lien** le rend inutilisable. Lien perdu ou expiré : créez-en un autre. + +Le lien d'invitation reprend l'adresse de la page ouverte. Pour la fixer, renseignez la clé `adresse` de `config/config.php`. + +## Créer un compte + +Vous pouvez aussi créer le compte vous-même. + +1. Saisissez le nom de la personne. +2. Choisissez son identifiant : 3 à 40 caractères, lettres sans accent, chiffres, point, tiret. +3. Choisissez un mot de passe provisoire : 12 caractères au moins. +4. Cliquez sur **Créer le compte**, puis transmettez l'identifiant et le mot de passe à la personne, de vive voix ou par un canal différent de l'identifiant. + +À sa première connexion, la personne choisit son propre mot de passe, puis active la double authentification. + +## Lire la liste des comptes + +Chaque compte affiche son identifiant, son rôle, l'état de sa double authentification et sa dernière connexion. **Double authentification à activer** signale un compte qui ne l'a pas encore faite. **Mot de passe à changer** signale un mot de passe provisoire pas encore remplacé. + +## Modifier un compte + +Sous chaque compte, **Modifier le nom, l'identifiant ou le mot de passe**. Laissez le mot de passe vide pour le garder. + +Un nouveau mot de passe est provisoire : la personne le change à sa prochaine connexion, et ses sessions ouvertes se ferment. Pour le donner, retapez votre propre mot de passe. + +- **Désactiver le compte** : la personne ne peut plus se connecter et ses sessions se ferment aussitôt. Ses actions restent dans le journal. **Réactiver le compte** rétablit l'accès. +- **Confier la gestion des comptes** : le compte devient gestionnaire. **Retirer la gestion des comptes** fait l'inverse. Retapez votre mot de passe pour confirmer. +- **Ses connexions** ouvre le journal des connexions de ce compte. + +Vous ne pouvez ni désactiver votre propre compte, ni retirer votre propre rôle. Un compte ne se supprime pas : le journal garde ses actions. + +## Mot de passe oublié + +Donnez un mot de passe provisoire par **Modifier le nom, l'identifiant ou le mot de passe**. La personne le remplace à la connexion. + +## Téléphone perdu + +La personne se connecte d'abord avec un code de secours, puis configure une autre application depuis **Mon compte**. + +Plus de code de secours : + +1. Vérifiez qu'il s'agit bien de la personne : par téléphone, en personne. Une demande par email ne suffit pas. +2. Sous son compte, **Remettre à zéro la double authentification**, puis retapez votre mot de passe. +3. La personne active une nouvelle application à sa prochaine connexion. Ses sessions ouvertes sont fermées. + +## Connexions bloquées + +Le bloc **Connexions bloquées** apparaît quand un blocage est en cours : + +- un identifiant bloqué depuis une adresse, après 5 échecs (15 minutes, puis 30 et 60) +- une adresse bloquée pour tous les identifiants, après 20 échecs en une heure + +**Débloquer** lève le blocage tout de suite. Faites-le seulement si vous savez qui essaie, par exemple un collègue qui s'est trompé de mot de passe. Des échecs en série depuis une adresse inconnue : laissez le blocage et regardez le journal des connexions. + +## Demandes faites avant la version 1.8 + +La page publique de demande de compte n'existe plus. Les demandes restées en attente s'affichent dans **Demandes à valider**. **Accepter** ouvre l'accès : la personne active la double authentification à sa connexion. **Refuser** efface la demande. + +# Journal des connexions + +Menu **Gestion**, **Connexions**. + +Chaque essai de connexion y figure : date, compte, identifiant saisi, adresse IP, réseau, navigateur, résultat. Filtrez par compte ou gardez les échecs seulement. Le journal garde 12 mois. La tâche planifiée et la connexion d'un gestionnaire effacent les lignes plus anciennes. + +| Résultat | Sens | +|---|---| +| Connexion réussie | Mot de passe et code bons | +| Mot de passe incorrect | Identifiant connu ou non, mauvais mot de passe | +| Code incorrect | Bon mot de passe, mauvais code de double authentification | +| Essai bloqué | Essai refusé pendant un blocage | +| Compte désactivé | Essai sur un compte désactivé | + +**L'alerte par email.** Quand un compte gestionnaire se connecte depuis un réseau jamais vu pour ce compte, Vivier envoie un email aux adresses de `comptes.notifier`, ou à l'adresse d'expédition. Le réseau est la plage d'adresses (les trois premiers nombres d'une adresse IPv4). La première connexion d'un compte ne déclenche pas d'alerte. + +Vous ne reconnaissez pas une connexion : changez le mot de passe du compte, remettez à zéro sa double authentification, puis fermez ses sessions en le désactivant et en le réactivant. + +# Erreurs + +Menu **Gestion**, **Erreurs**. + +Quand une page échoue, l'utilisateur lit « Une erreur est survenue » et une référence de six caractères. La page **Erreurs** liste les 300 dernières, avec la référence, la date, la page, la cause et l'endroit du code. Aucune donnée de candidat n'y figure. Transmettez la ligne à la personne qui maintient Vivier. **Vider le journal** efface la liste. + +# Suppression des profils et corbeille + +Seul le gestionnaire supprime un profil : bouton **Supprimer le profil** en bas de la fiche, ou **Supprimer** sur la page Conservation. Les autres comptes ne voient pas ces boutons. + +## Ce que fait la suppression + +Le profil part à la corbeille. Il disparaît aussitôt du Kanban, des profils, de la recherche, des besoins, du suivi, du tableau de bord, de l'export CSV et de la page Conservation. Sa fiche et ses documents ne s'ouvrent plus. Dans le journal, son nom est remplacé par sa référence. + +## La page Corbeille + +Menu **Gestion**, **Corbeille**. Chaque ligne donne le profil, la date de suppression, le compte qui a supprimé et la date d'effacement. + +- **Restaurer** remet le profil en place, avec toutes ses données. Il revient en bas de sa colonne. +- **Effacer maintenant** efface le profil sans attendre. L'action est sans retour. + +## L'effacement après 30 jours + +Au bout de 30 jours, Vivier efface la fiche, le CV d'origine, la photo, les pièces jointes, les commentaires, les entretiens, les relances, les mots-clés et l'historique. Le journal garde une ligne : la référence du profil, le compte qui a supprimé et la date de mise à la corbeille. Aucun nom. + +L'effacement se fait à trois moments : la tâche planifiée du dimanche, la connexion d'un gestionnaire, l'ouverture de la page Corbeille. Un profil peut donc rester quelques jours de plus si personne ne se connecte. + +Le délai se règle par `CORBEILLE_JOURS`, en tête de `src/bootstrap.php`. + +## À savoir + +- Tant qu'un profil est à la corbeille, son adresse email reste prise. L'import du même candidat est refusé : restaurez le profil ou effacez-le. +- Les sauvegardes déjà faites gardent les profils supprimés depuis. Une sauvegarde faite pendant les 30 jours contient les profils à la corbeille. + +# Sauvegarde + +Menu **Gestion**, **Sauvegarde**. + +## Ce qui est sauvegardé + +| Élément | Où | Comment | +|---|---|---| +| Base de données | Email et copie sur le serveur | Chaque semaine, par la tâche planifiée | +| Fichiers : CV, rapports, pièces jointes, photos | Téléchargement | À la main, bouton **Télécharger l'archive des fichiers**, après avoir retapé votre mot de passe | + +La base contient les profils, les commentaires, les entretiens, les relances, les besoins, le journal et les comptes. Les fichiers sont trop lourds pour un email. + +## La sauvegarde hebdomadaire + +La tâche planifiée de l'hébergement lance le script `cron/sauvegarde.php`. Le script : + +1. exporte toute la base dans un fichier SQL +2. le compresse en zip +3. garde une copie dans `stockage/sauvegardes/` +4. envoie le zip aux adresses réglées dans `config/config.php` + +Le fichier n'est pas chiffré. Il contient les données des candidats, les empreintes des mots de passe et les clés de double authentification chiffrées. Sa protection est celle des boîtes qui le reçoivent : mot de passe solide et double authentification. + +## Programmer la tâche chez OVH + +1. Espace client, hébergement, onglet **Plus**, **Planificateur de tâches**, **Ajouter une planification**. +2. Commande : `vivier/cron/sauvegarde.php`. Langage : PHP 8.3. +3. Fréquence : le jour et l'heure voulus. OVH ne règle pas les minutes : une tâche prévue à 6 h part entre 6 h 00 et 6 h 59. +4. Adresse du rapport : facultative. OVH y envoie ce que le script affiche. + +## Vérifier que tout marche + +1. Page Sauvegarde, **Envoyer un email d'essai, sans pièce jointe**. Regardez les boîtes de réception, courriers indésirables compris. +2. Cochez **Envoyer aussi la sauvegarde par email**, puis **Sauvegarder maintenant**. +3. Ouvrez le zip reçu : il contient un fichier `.sql`. +4. Après le premier passage de la tâche planifiée, vérifiez la ligne « Tâche planifiée » dans l'historique. + +Si l'email arrive dans les courriers indésirables, créez une règle pour l'expéditeur. Le domaine de l'adresse d'expédition doit autoriser le serveur d'OVH dans son enregistrement SPF, et ne publier qu'un seul enregistrement SPF. + +## Les alertes + +| Signal | Sens | +|---|---| +| Email « ÉCHEC de la sauvegarde » | La sauvegarde a échoué, ou elle est faite mais son envoi a échoué | +| Bandeau rouge sur le tableau de bord | Aucune sauvegarde réussie depuis plus de 8 jours, ou un problème à la dernière | + +Le bandeau couvre le cas où la tâche ne démarre plus ou l'email ne part plus : dans ce cas, aucun email d'alerte n'arrive. + +## Les copies sur le serveur + +L'historique de la page liste chaque sauvegarde. **Télécharger** récupère le zip. **Supprimer** efface la copie du serveur, pas les emails reçus. + +Le réglage `semaines_conservees` fixe la durée : 0 garde toutes les copies, un nombre supprime les copies plus anciennes. + +Une copie garde les profils supprimés depuis. Pour respecter la durée de conservation des données, supprimez les copies et les emails de sauvegarde anciens. + +## Si le fichier devient trop lourd + +Au-delà de `taille_max_email`, Vivier refait l'export sans l'index de recherche, qui se reconstruit. S'il reste trop lourd, l'email part sans pièce jointe et le fichier se télécharge dans Vivier. + +## Restaurer + +1. phpMyAdmin, base de Vivier, onglet **Importer** : chargez le fichier `.sql` extrait du zip. Il remplace toutes les tables. +2. Par FTP, copiez les dossiers `docs/` et `photos/` de l'archive des fichiers dans `stockage/`. +3. Si le fichier SQL annonce en tête que l'index de recherche n'est pas inclus : menu Gestion, Comptes, **Réindexer tous les profils**. +4. Gardez `config/cle-secrets.php`, ou la clé `cle_secrets` : elle déchiffre les clés de double authentification de la sauvegarde. +5. Connectez-vous. Les comptes, les mots de passe et la double authentification sont ceux du jour de la sauvegarde. + +Téléchargez l'archive des fichiers le même jour que la sauvegarde de la base : la base ne retrouve que les fichiers qui existaient ce jour-là. + +# Installation sur l'hébergement OVH + +Vivier demande PHP 8.3 et une base MySQL ou MariaDB. Aucun framework, aucune dépendance à installer. + +1. Espace client, onglet **Bases de données** : créez une base MySQL. Notez l'hôte, le nom, l'utilisateur et le mot de passe. +2. phpMyAdmin, onglet **Importer** : chargez `sql/schema.sql`. +3. Envoyez tout le dossier `vivier/` par FTP, à côté de `www/`. +4. Onglet **Multisite** : ajoutez le sous-domaine avec pour dossier racine `vivier/public`. Cochez SSL. +5. Copiez `config/config.exemple.php` en `config/config.php` et renseignez la base. +6. Ouvrez l'adresse du site en `https://`. La page de premier lancement crée votre compte, gestionnaire des comptes, puis vous fait activer la double authentification. +7. Programmez la sauvegarde hebdomadaire. + +Le dossier racine doit être `vivier/public`. Les autres dossiers restent ainsi inaccessibles par une adresse. Des fichiers `.htaccess` les bloquent aussi, en seconde protection. + +| Dossier | Rôle | +|---|---| +| `public/` | Racine web : `index.php`, styles, scripts, polices | +| `src/` | Code PHP | +| `vues/` | Écrans | +| `lang/` | Traductions de l'interface et des CV | +| `aide/` | Les textes de cette aide | +| `config/` | Configuration | +| `stockage/` | CV, documents, photos, sauvegardes | +| `cron/` | Script de la sauvegarde hebdomadaire | +| `sql/` | Création de la base et migrations | +| `claude/` | Consignes, schéma et exemple du projet Claude d'analyse | +| `tests/` | Tests | + +# Mise à jour + +1. Exportez la base dans phpMyAdmin, onglet **Exporter**. +2. Exécutez dans l'ordre les fichiers `sql/migration-*.sql` qui n'ont pas encore été passés. + La migration 1.5 ajoute la corbeille et les demandes de compte. Elle crée aussi le compte gestionnaire `ypilla`, ou met à jour son mot de passe et son rôle s'il existe. + La migration 1.8 ajoute la double authentification, les invitations, le journal des connexions, la nationalité, le permis de travail, la disponibilité classée et les champs du CV Europass. Elle classe les disponibilités déjà saisies et garde le texte d'origine en note. +3. Remplacez par FTP les dossiers `public/`, `src/`, `vues/`, `lang/`, `aide/`, `cron/`, `sql/`, `claude/` et `tests/`. Gardez `config/config.php` et `stockage/`. +4. Reportez dans `config/config.php` les nouvelles clés de `config/config.exemple.php`. En 1.8 : `adresse`, `cle_secrets`, `securite`. +5. Le dossier `config/` doit être accessible en écriture au premier lancement : Vivier y crée `cle-secrets.php`. Sinon, renseignez `cle_secrets` vous-même. +6. Dans Vivier : menu Gestion, Comptes, **Réindexer tous les profils**, puis **Traduire tous les profils**. +7. Dans le projet Claude d'analyse : remplacez les consignes, le schéma et l'exemple par ceux du dossier `claude/`. + +Après la mise à jour 1.8, chaque compte active la double authentification à sa connexion suivante. Prévenez les utilisateurs : il leur faut leur téléphone. + +# Configuration + +Tout se règle dans `config/config.php`. Ce fichier ne doit jamais être publié. + +| Clé | Rôle | +|---|---| +| `nom_app` | Le nom affiché dans l'en-tête | +| `fuseau` | Le fuseau horaire des dates | +| `db` | L'hôte, le port, le nom, l'utilisateur et le mot de passe de la base | +| `societe` | Les mentions d'IT Club sur les CV exportés | +| `claude` | La clé d'API, le modèle, la longueur maximale de réponse, le délai | +| `sauvegarde` | Les destinataires, l'expéditeur, la durée des copies, la taille maximale d'un email | +| `stockage` | Le dossier des fichiers, hors de la racine web | +| `taille_max` | La taille maximale d'un fichier chargé, en octets | +| `conservation_mois` | La durée de conservation d'un profil, en mois | +| `comptes` | `notifier` : les adresses qui reçoivent l'alerte de connexion d'un gestionnaire depuis un nouveau réseau | +| `adresse` | L'adresse publique de Vivier, reprise dans les liens d'invitation. Facultative | +| `cle_secrets` | La clé de chiffrement des clés de double authentification. Vide : Vivier crée `config/cle-secrets.php` | +| `securite` | `mots_de_passe_fuites` : `false` coupe le contrôle des mots de passe connus dans des fuites | +| `https` | `true` en production : HTTPS obligatoire, en-tête HSTS, cookie de session limité à HTTPS | + +# Analyse automatique + +Pour l'activer, renseignez `cle_api` dans la clé `claude` de `config/config.php`. Sans clé, seul l'import par fichiers déjà analysés est proposé. + +- La clé se crée dans la console d'Anthropic. L'usage de l'API est facturé à part d'un abonnement Claude. +- Formats lus : PDF, DOCX, ODT, MD, TXT. +- Les consignes envoyées à Claude sont celles du dossier `claude/`. Les modifier change les deux voies d'analyse. +- Le CV est transmis à Anthropic pour l'analyse. +- Délai maximal : 240 secondes, réglable. Si l'hébergement coupe avant, passez par les fichiers déjà analysés. + +# Traduction des profils + +Chaque profil s'affiche dans la langue de l'interface. Un recruteur en anglais lit les intitulés, la synthèse, les expériences et les points d'attention en anglais. + +- À l'import, Vivier prévoit la traduction du profil dans chaque langue de l'interface. +- Les traductions se font en arrière-plan, une par une, tant qu'une page de Vivier reste ouverte chez un utilisateur. +- Une fiche modifiée est de nouveau mise en file. En attendant, le texte d'origine s'affiche avec un bandeau. +- Les commentaires, comptes rendus d'entretien, besoins clients et permis de travail ne sont pas traduits : ils restent dans la langue de leur auteur. +- Les noms de technologies, d'entreprises, de clients et de projets restent tels quels. + +Menu **Gestion**, **Comptes**, bloc **Maintenance** : + +- le nombre de traductions à faire +- les traductions abandonnées après 3 essais, avec leur cause +- **Traduire tous les profils** met en file chaque profil sans traduction à jour, et relance les traductions abandonnées + +Sans analyse automatique, les traductions passent par un fichier, depuis l'aperçu du CV IT Club, comme avant. + +# Recherche : index et synonymes + +Vivier range chaque fiche dans un index de recherche. L'index se met à jour à chaque import ou modification. + +**Réindexer tous les profils** (menu Gestion, Comptes) reconstruit l'index. À relancer après une mise à jour de Vivier ou une restauration sans index. + +Les synonymes se règlent dans la table `synonymes_recherche`, avec phpMyAdmin : + +| Type | Effet | Exemple | +|---|---|---| +| `seniorite` | Le mot devient un filtre de séniorité | `senior` vers `confirme` et `expert` | +| `texte` | Le mot gagne une variante | `dev` vers `développeur` | + +Un changement de synonyme agit tout de suite, sans réindexer. + +# Sécurité + +Le guide **Sécurité**, dans l'aide, décrit les protections pour les utilisateurs. Voici ce qui vous revient. + +| Réglage | Valeur | Où | +|---|---|---| +| Double authentification | Obligatoire pour tous, codes à 6 chiffres (RFC 6238), 10 codes de secours | `src/securite.php` | +| Mot de passe | 12 caractères au moins, 200 au plus, refusé s'il figure dans une fuite connue | `LONGUEUR_MOT_DE_PASSE`, `LONGUEUR_MAX_MOT_DE_PASSE` | +| Empreinte du mot de passe | Argon2id, recalculée à la connexion pour les anciens comptes | `hacher_mot_de_passe()` | +| Blocage d'un identifiant | 5 échecs : 15 min, puis 30 et 60 min | `ECHECS_SERIE`, `BLOCAGES_MINUTES` | +| Blocage d'une adresse | 20 échecs en une heure | `ECHECS_IP_HEURE` | +| Session | 2 h sans activité, 12 h au plus | `DUREE_INACTIVITE`, `DUREE_SESSION_MAX` | +| Lien d'invitation | 48 h, une seule fois | `INVITATION_HEURES` | +| Journal des connexions | 12 mois | `CONNEXIONS_MOIS` | + +Les constantes se trouvent en tête de `src/bootstrap.php` et de `src/securite.php`. + +Autres protections : + +- HTTPS obligatoire quand `https` vaut `true` : redirection des adresses en `http://` et en-tête HSTS d'un an. Ne passez `https` à `false` que pour un essai sur un poste local. +- Les clés de double authentification sont chiffrées (AES-256-GCM). La clé de chiffrement est dans `config/cle-secrets.php`, créé au premier besoin, ou dans la clé `cle_secrets` de `config/config.php`. Sauvegardez-la avec `config.php` : sans elle, chacun doit se connecter avec un code de secours puis réactiver sa double authentification. +- Mots de passe vérifiés auprès du service Pwned Passwords : seuls les 5 premiers caractères de l'empreinte SHA-1 partent. Si le service ne répond pas, le mot de passe est accepté. `securite.mots_de_passe_fuites` à `false` coupe ce contrôle. +- Votre mot de passe est redemandé pour confier ou retirer la gestion, donner un mot de passe à un autre compte, remettre à zéro une double authentification et télécharger l'archive des fichiers. +- Export CSV, journal, conservation, corbeille, suppression des profils : gestionnaire seulement. +- Site exclu des moteurs de recherche : en-tête `X-Robots-Tag` et balise `robots` sur chaque page. +- Fichiers stockés hors de la racine web, sous un nom aléatoire. +- Chaque action est tracée dans le journal, avec son auteur. +- Aucun appel à un service externe, sauf l'API Claude quand l'analyse automatique est activée, et Pwned Passwords. + +# Langues + +L'interface existe en français et en anglais. Les CV se génèrent en français, anglais et allemand. + +Ajouter une langue d'interface : + +1. Copiez `lang/en.php` en `lang/xx.php` et traduisez les valeurs. Une clé non traduite s'affiche en français. +2. Ajoutez la langue à `LANGUES_INTERFACE` dans `src/i18n.php`. +3. Copiez `aide/guide.en.md` et `aide/administration.en.md` avec le code de la langue, et traduisez-les. Sans ces fichiers, l'aide s'affiche en français. + +Pour une langue de CV, quelques dizaines de libellés suffisent : voir `lang/de.php`. La langue s'ajoute à `LANGUES_CV`. + +# Dépannage + +| Symptôme | Cause probable | Que faire | +|---|---|---| +| « Configuration absente » | `config/config.php` manque | Copiez `config/config.exemple.php` et renseignez-le | +| « Une erreur est survenue » | Erreur côté serveur | Menu Gestion, **Erreurs** : cherchez la référence donnée par l'utilisateur | +| Un utilisateur n'a plus son téléphone ni ses codes | Double authentification perdue | Vérifiez son identité, puis **Remettre à zéro la double authentification** | +| « Trop d'essais infructueux » | Blocage après des échecs | Attendez, ou **Débloquer** dans Comptes si vous savez qui essaie | +| Code de double authentification toujours refusé | Heure du téléphone décalée | Réglez l'heure du téléphone en automatique | +| Personne ne peut plus valider son code | `config/cle-secrets.php` perdu ou changé | Remettez le fichier d'origine. Sinon, chacun se connecte avec un code de secours et réactive sa double authentification | +| « Vivier ne s'utilise qu'en HTTPS » | Formulaire envoyé en `http://` | Ouvrez l'adresse en `https://`. Vérifiez le certificat SSL du multisite | +| Profils restés en français en anglais | Traductions pas faites | Comptes, **Traduire tous les profils**, puis gardez une page ouverte | +| L'analyse automatique dépasse le délai | Limite de durée de l'hébergement | Passez par les fichiers déjà analysés | +| La recherche ne trouve rien | Index vide | **Réindexer tous les profils** | +| Aucun email de sauvegarde | Tâche arrêtée, SPF, courriers indésirables | Email d'essai, puis vérifiez la tâche dans l'espace client | +| Un chargement de fichier échoue | Fichier au-dessus de 10 Mo | Réduisez le fichier, ou relevez `taille_max` et la limite de PHP | + +Les tests se lancent en ligne de commande : `php tests/test_import.php`, puis de même pour `test_recherche.php`, `test_photo.php`, `test_i18n.php`, `test_suivi.php`, `test_aide.php` et `test_securite.php`. diff --git a/aide/guide.en.md b/aide/guide.en.md new file mode 100644 index 0000000..bb47f2d --- /dev/null +++ b/aide/guide.en.md @@ -0,0 +1,598 @@ +# Vivier at a glance + +Vivier tracks the CVs IT Club receives, from first contact to hiring. + +- Each CV becomes a **profile**: identity, experience, technologies, languages, seniority. +- Profiles move across a six-column **board**. +- On each profile you record interviews, follow-ups, comments and documents. +- **Client requests** describe the assignments to fill. Vivier suggests the profiles that match. +- Vivier generates a **CV in the IT Club format** and a **Europass CV**, named or anonymised, in French, English or German. +- Each profile shows in the interface language, translated automatically. +- Every action is recorded in an activity log. + +Every account can read and edit every profile. The account manager also has the **Manage** menu: accounts, activity log, retention, trash, backup. + +The **Security** guide explains how Vivier protects your access and what you can do on your side. + +# Signing in + +1. Open the Vivier address in your browser. +2. Enter your username and password, then **Continue**. +3. Enter the 6-digit code shown by the authenticator app on your phone. + +Good to know: + +- A password is at least 12 characters long. Vivier rejects passwords found in known data breaches. +- After 5 failed attempts, sign-in is blocked for 15 minutes for that username, then longer. +- The session closes after 2 hours without activity, and at the latest 12 hours after sign-in. Vivier then brings you back to the page you had open. +- Phone not at hand: type a backup code in the code field. +- Forgotten password, or lost phone with no backup code: contact the account manager. + +The **Security** guide covers each point in detail. + +## Getting an account + +Vivier has no open sign-up. + +1. The account manager sends you an invitation link. It works once and expires after 48 hours. +2. Open the link. Choose your username and password. +3. Set up two-step verification: install an authenticator app on your phone, scan the QR code, enter the code shown. +4. Keep the ten backup codes Vivier gives you. They are shown only once. + +The account manager can also create your account with a temporary password. At first sign-in, you choose your own, then set up two-step verification. + +# The screen and the menu + +The black header stays at the top of every page. + +| Item | Purpose | +|---|---| +| Board | Active profiles, sorted by status | +| Profiles | The full list and the search | +| Requests | Assignments to fill at clients | +| Follow-up | Follow-ups due and upcoming interviews | +| Dashboard | The pool's figures | +| Manage | For the account manager only: Retention, Activity log, Sign-ins, Accounts, Trash, Backup, Errors | +| Search field | Searches every profile | +| Import | Adds one or several CVs | +| Question mark | Opens this help | +| Your initials | My account, interface language, sign out | + +A gold badge on **Follow-up** counts the follow-ups that are due. For the account manager, a badge on **Manage** adds up the profiles past their retention date and the account requests to approve. The submenu gives the detail. + +On a narrow screen, the **Menu** button unfolds these items. + +# Importing a CV + +**Import** button, in the header. Two routes lead to the same review screen. + +## Automatic analysis + +1. In the **Automatic analysis** card, choose the CV: PDF, DOCX, ODT, MD or TXT. +2. Click **Analyse CV**. Allow one to two minutes, without closing the page. +3. Vivier shows the review screen. + +This route needs a Claude API key, set by the account manager. Without a key, the card says "Not set up". + +A DOC or RTF file is not read by this route: convert it to PDF. + +## Files already analysed + +You run the analysis yourself in a Claude project, then upload the result. + +1. In the Claude analysis project, attach the CV and write: "analyse ce CV pour Vivier". +2. Claude produces two files: the structured profile (`.json`) and the detailed report (`.md`). Download them. +3. In Vivier, **Files already analysed** card, upload the JSON, the original CV and the report. The report is optional. +4. Click **Check profile**. + +If the JSON does not comply, Vivier rejects the import and lists every issue to fix. + +## Review before saving + +The review screen shows what Vivier is about to save. + +- Check the identity, email, phone number and the dates of each position. +- **Seniority** is recalculated by Vivier from the dates. If it differs from the analysis, a message says so. +- The **points to check** raised by the analysis are shown: gaps in the career, vague dates, missing information. +- If the CV contains a **photo**, it appears with the **Keep this photo** box. Untick it to discard the photo. + +Click **Save profile**. The profile lands in the **Received** column. **Cancel** drops the import and deletes the uploaded files. + +## Duplicates + +Vivier compares the profile with existing ones, archived profiles included. A profile in the trash counts too: it is shown by its reference, and the account manager can restore or erase it. + +| Similarity | Effect | +|---|---| +| Same email address | Saving is refused. Update the existing profile instead. | +| Same phone number | Warning. A box confirms this is someone else. | +| Same first name and name | Warning. A box confirms this is someone else. | + +Phone numbers are compared without country code, spaces or punctuation. Names are compared without case, accents or order. + +## Importing several CVs + +Import page, **Import several CVs** button. The batch holds 20 CVs at most. + +**CVs to analyse**: select several files, then **Add the CVs**. Vivier analyses them one after the other. Keep the page open. If you close it, the analyses resume when you open it again. + +**Profiles already analysed**: select the JSON files, the CVs and the reports together, then **Add the files**. Vivier pairs each JSON with the CV named in its `source.fichier_origine` field. A report is paired when its name repeats the JSON's name or the candidate's name. + +Each line of the batch has a status: + +| Status | Next step | +|---|---| +| Waiting | The analysis is about to start | +| Ready for review | **Review** opens the review screen | +| Error | The reason is shown. **Run the analysis again** or **Remove** | + +**Save the ready profiles** saves in one go those that look like no existing profile. The others stay in the batch, to review one by one. A CV left in the batch is removed after 7 days. + +# The board and the statuses + +The board shows active profiles in six columns. + +| Status | Meaning | +|---|---| +| Received | The CV has just come in | +| In progress | The profile is being studied, before any interview | +| Interviewing | One or more interviews are planned or done | +| Approved | IT Club selects the profile | +| Hired | The candidate has joined IT Club | +| Rejected | The profile is set aside. A reason is required | + +## Moving a card + +- Drag the card from one column to another. The change is saved at once. +- Moving to **Rejected** opens a window that asks for the reason. +- Within a column, drag a card to change the order. +- On a touch screen, press and hold to pick up the card. + +The status can also be changed on the profile page. + +## Reading a card + +A card shows the photo or initials, the name, the title, the seniority, the years of experience and the three main technologies. When they exist, it also shows: + +- the date of the next interview +- the number of follow-ups due +- a red marker when the residence permit expires within 2 months, or has expired +- the reason, in the Rejected column + +The stacked bars left of a column name show progress: one bar for Received, five for Hired. + +## Filtering + +The **Filter cards** field keeps the cards that contain the words typed: name, title or technology. The **Seniority** list keeps one level. The filter only affects the screen. + +# The profile page + +Click a name to open the profile. It has a header band and four tabs. + +## The header band + +The band stays at the top of the profile, whatever the tab. + +- The photo, the name, the title, the seniority and the years of experience. +- **Original CV**: the file received from the candidate. +- **IT Club CV** and **Europass CV**: choose **Named** or **Anonymised** to open the preview of the generated CV. +- **Edit**: the profile in six blocks, described below. Vivier saves the last name in capitals. +- **Status**: choose the status then **Update**. +- The candidate's availability, expected daily rate and city. +- The reason, if the profile is **Not selected**. + +A **To do** line appears when an action is waiting: overdue follow-up, interview feedback to give, next interview, residence permit to renew, retention period over. A click opens the matching tab and section. Red dots mark something overdue. + +## Editing the profile + +**Edit** button. Six blocks: + +| Block | Content | +|---|---| +| Identity and CV | First name, last name, title, summary. Used on the CVs | +| Contact details | Email, phone, address. Only the city and country appear on the named CV | +| Nationality and work permit | Up to three nationalities, the work permit, its expiry date. On no CV | +| Availability | A choice from a list, a date if needed, a free note | +| Commercial details | Daily rate, salary, mobility, remote work. On no CV | +| Europass CV | Volunteering and driving licence, used on the Europass CV only | + +**Availability.** Choose Immediate, Within 1 month, Within 2 months, Within 3 months, Specific date or To be confirmed. "Specific date" asks for the date. "Within N months" computes an estimated date, used for matching with client requests. The free note keeps an internal detail, for example a notice period to negotiate. + +**Nationality and work permit.** Pick the nationality from the list: type the first letters of the country to go faster. An EU, EEA or Swiss nationality needs no work permit in Luxembourg: Vivier says so and hides the permit fields. Otherwise, describe the permit in free text and enter its expiry date. Vivier then plans a follow-up 2 months before, shown in **Follow-up**. Do not upload a copy of the permit to Vivier. + +The analysis picks up the nationality when the CV states it. The work permit is always entered by hand. + +## The four tabs + +The tabs stay under the header. With the keyboard, the arrow keys move from one tab to the next. + +| Tab | Content | +|---|---| +| Profile | Summary, points to check, experience, technologies, education and certifications, contact details, nationality and work permit, languages, volunteering | +| Follow-up | Interviews, comments, follow-ups | +| Assignments | Client requests and commercial details | +| File | Documents, keywords and classification, history, personal data, photo, manage | + +The profile opens on the **Profile** tab. After you save something, Vivier returns to the tab you were on: a posted comment brings you back to **Follow-up**, an added document to **File**. Links on the **Follow-up** page open the profile on the **Follow-up** tab. + +A red badge on **Follow-up** counts overdue follow-ups and interview feedback to give. An exclamation mark on **File** flags a retention period that is over. The small grey number next to a tab counts what it holds. + +On the **Profile** tab, the three most recent positions and the ten most practised technologies are shown. The **Show…** link below the list expands the rest. The add forms (interview, follow-up, document, photo) open with a click on their title. + +## Comments + +Write in **New comment**, then **Post**. A comment carries your name and the date. It cannot be edited. + +## Documents + +- The original CV and the report are attached at import. +- **Add a document**: choose the file and its type. Maximum size: 10 MB. +- Accepted formats: PDF, DOCX, DOC, ODT, RTF, MD, TXT, PNG, JPG, XLSX. +- **Read** opens a `.md` or `.txt` report inside Vivier. + +## Keywords + +Keywords come from the analysis. They improve the search. + +- Add a word with its type, then **Add**. A word added by hand shows on a light background. +- The cross removes a word. +- Words added by hand survive an update of the analysis. + +## Photo + +The photo helps recognise the candidate in Vivier: profile page, board, search results. It never appears on an exported CV. Vivier does not analyse it. + +## Commercial details + +**Edit** button, **Commercial details** block: expected daily rate, expected gross annual salary, mobility, remote work. + +These details feed the matching with client requests and the CSV export. They never appear on an exported CV. Availability has its own block. + +# Interviews, follow-ups and tracking + +## Recording an interview + +On the profile page, **Follow-up** tab, **Interviews** section, **Add an interview**. + +1. Enter the date. The time is optional. +2. Choose the type: phone, video call, on site, technical interview, client interview. +3. Say who conducts the interview and, if there is one, the related client request. +4. Click **Save the interview**. + +For a Received or In progress profile, the **Move the profile to "Interviewing"** box is ticked: the profile changes column at the same time. + +After the interview, open **Add the assessment and notes**: positive, mixed or negative assessment, then the notes. A past interview without assessment is marked "assessment needed". + +## Planning a follow-up + +On the profile page, **Follow-up** tab, **Follow-ups** section, open **Plan a follow-up**: write what needs doing, choose the due date, then **Plan the follow-up**. + +When it is done, click **Mark as done**. The follow-up stays on the profile, dated and signed. + +## The Follow-up page + +**Follow-up** menu. The title counts the planned follow-ups and, in red, those due today or overdue. Three lists: + +- **Follow-ups**: oldest first. A follow-up that is due shows in red. +- **Interviews in the next 30 days**. +- **Past interviews without assessment**: to complete. + +Archived profiles are not listed. + +# Client requests and matching + +**Requests** menu. A request describes an assignment to fill. + +## Creating a request + +Open **New request**. The client and the job title are required. Fill in what you know: + +- the required technologies, separated by commas +- the minimum seniority +- the required languages +- the location and remote work +- the start date and the daily budget + +The more precise the request, the more precise the matching. + +## Reading the matching + +The page of an **open** request lists the profiles in the pool that match it, best scores first. + +- The score, out of 100, shows on the right of each profile. +- Technologies found on the profile show on a gold background, with the years of practice. +- Missing technologies show with a dashed outline. +- Gaps show in red: lower seniority, missing language, availability too late, daily rate above the budget. + +| Criterion | Points | Rule | +|---|---|---| +| Technologies | 55 | Share of the required technologies found on the profile. A technology found earns 60% of its share, 100% from 5 years of practice | +| Job title | 15 | Share of the words of the job title found on the profile | +| Seniority | 10 | 10 if the level is reached, 4 one level below, 0 otherwise | +| Languages | 10 | Share of the required languages found on the profile | +| Availability | 5 | 5 if the profile is available before the start, 0 after | +| Rate | 5 | 5 if the daily rate fits the budget, 2 up to 10% above, 0 beyond | + +A criterion missing from the request earns all its points. A value missing from the profile earns half. Archived and rejected profiles are left out. + +The score ranks the profiles. It does not replace reading the profile. + +## Putting a profile forward + +- On the request page: **Put forward**, next to the profile. +- On the profile page: **Assignments** tab, **Client requests** section, choose the request, then **Put forward**. + +A profile put forward goes through four statuses: + +| Status | Meaning | +|---|---| +| To present | The profile is selected internally | +| Presented to the client | The CV has gone to the client | +| Selected by the client | The client wants this profile | +| Set aside | The profile does not fit this request | + +**Update**, under the profile's name, changes the status and adds a note. + +## Closing a request + +Change the **Request status**: Open, Filled or Closed. Only an open request shows the matching. Deleting a request deletes its list of profiles put forward. The profiles stay. + +# Searching for a profile + +The header field and the **Profiles** menu search every profile. + +## Writing a search + +- Every word typed must appear on the profile. +- Accents, capitals and plurals are ignored. +- A word of three letters or more also finds words that start with it: `dev` finds "developer". +- `senior` keeps Senior and Expert profiles. `junior` and `expert` filter the same way. +- A phrase in quotation marks is searched as is: `"gestion documentaire"`. +- Technology symbols are kept: C#, C++, .NET, Node.js, CI/CD. + +| Search | Result | +|---|---| +| `senior java developer` | Senior or Expert profiles that carry "developer" and "java" | +| `XML` | Profiles that mention XML, anywhere | +| `angular luxembourg` | Profiles that carry both words | +| `"product owner"` | Profiles that carry the exact phrase | + +Vivier searches both the original texts and their translation. + +## Ranking and filters + +Results are ranked by relevance. A word weighs more in the name, the title or the technologies than in a job description. Technologies and keywords found show on a gold background. + +Six filters: status, seniority, language, nationality, residence permit, archived profiles. Archived profiles are hidden by default. Changing a filter runs the search at once. On a phone, the **Filters** button shows or hides them. + +| Filter | Values | +|---|---| +| Nationality | EU, EEA or Switzerland (no work permit needed), Non-EU, Not specified | +| Residence permit | Expired or expiring within 2 months | + +The search finds a profile through its original texts and their translation: `developer` and `développeur` return the same profiles. + +**Export to CSV** downloads the profiles shown. This button is for the account manager only. + +# The CV in the IT Club format + +On a profile page, **IT Club CV** and **Anonymised CV** open an A4 preview. + +## What the CV contains + +- The name, the title, the seniority, the languages, the availability. +- The summary, the technical skills, the experience with its project, assignment and workload, the education, the certifications. +- IT Club's details in the footer. + +The CV carries no personal contact details: contact goes through IT Club. It carries no photo, no commercial details, no nationality and no work permit. + +The **anonymised version** replaces the name with a reference, for example "Profile ITC-0001", and hides the location. Employer and client names stay visible. + +## Exporting + +| Button | Result | +|---|---| +| Download as Word | An editable `.docx` file | +| Save as PDF | The browser's print dialog. Choose "Save as PDF" and untick "Headers and footers" | + +Each export is recorded in the profile's history. + +## Changing the CV language + +In the preview bar, choose FR, EN or DE. With the interface in English, the preview opens in English. In French, it opens in the language of the original CV. + +- The CV headings follow the chosen language. +- The content stays in the language of the original CV until it is translated. The preview says so. + +To translate the content: + +- **Translate content**, if automatic analysis is set up. Review the result before sending the CV. +- **Translate with a file**, otherwise: download the file, drop it in the Claude analysis project, then upload the translated file. + +The translation is kept. It also serves the profile page and the Europass CV. If the profile changes afterwards, Vivier redoes the translation in the background. Meanwhile, the original content comes back. Names of technologies, companies and clients are not translated. + +# The Europass CV + +On a profile page, **Europass CV**, then **Named** or **Anonymised**. The preview follows the layout of IT Club's Europass CV: blue band, Europass logo, sections About me, Education and training, Work experience, Language skills, Skills, Volunteering, Driving licence. + +## What the CV contains + +- Positions: employer, location, dates, job title, client, project, assignment, technologies, workload, then achievements. +- Education with its dates and location, when known. +- Languages: the European grid (understanding, speaking, writing) when the original CV gives it, otherwise one line per language, for example "English: C1". +- Skills, grouped: certifications, technical, databases, software and tools, cloud, methods. +- Volunteering and driving licence, only when filled in. + +The Europass CV carries no date of birth, no nationality, no work permit and no commercial details. + +## Preview settings + +| Button | Effect | +|---|---| +| Named, Anonymised | The anonymised version replaces the name with the reference and removes photo and contact details | +| Contact details | Shows or removes the email, phone, LinkedIn profile and address | +| Photo | Adds the profile photo. Off by default | +| FR, EN, DE | The CV language, as for the IT Club CV | +| Download as Word | An editable `.docx` file | +| Save as PDF | The browser's print dialog, in A4 | + +Each export is recorded in the profile's history. + +# Profiles in your language + +Vivier shows each profile in the interface language. In English, you read the title, summary, positions, education, volunteering and points to check in English. + +- Translation runs automatically after import, in the background, while a Vivier page stays open. +- A profile not translated yet shows in its original language, with a banner. **Translate now** starts the translation right away. +- Under a translated profile, **View the original** shows the original text. +- Comments, interview reports, client requests and the work permit stay in their author's language. +- Names of technologies, companies, clients and projects are not translated. + +The translation is automatic. Review it before sending a CV. + +# Dashboard and CSV export + +**Dashboard** menu. + +- The black band gives the key figures: profiles in the pool, received in 30 days, in progress, hired, interviews within 7 days, follow-ups due, open requests, retention to handle. +- **Profiles by status** and **Seniority**: clicking a line opens the matching list. +- **Average time spent at each stage**: time between entering a status and leaving it. +- **Most common technologies**: a click runs the search. + +Archived profiles are counted separately. + +The CSV export (Profiles menu, **Export to CSV**) holds one line per profile shown, commercial details, nationality and work permit included. The separator is the semicolon: Excel opens the file directly. This export, which takes the whole pool out at once, is for the account manager only. + +# Personal data and retention + +Vivier holds personal data. Three tools help comply with the GDPR. + +## The candidate's consent + +On the profile page, **File** tab, **Personal data** section, **Record the candidate's consent**: record the date the candidate agreed that IT Club keeps their CV, and how they said so. The consent can be edited and withdrawn. + +## The retention period + +Each profile has a retention date, set at import: 24 months by default. + +The account manager tracks retention dates: **Manage** menu, **Retention**. The page lists the profiles whose date has passed or falls within 60 days. + +- **Extend** pushes the date back, if the candidate has confirmed their consent. +- **Delete** moves the profile to the trash. + +On the profile page, the **To do** line flags a retention period that is over to everyone. Tell the account manager if the candidate has renewed their consent. + +## Exporting a candidate's data + +On the profile page, **File** tab, **Personal data** section, **Export the candidate's data**. Vivier downloads a zip with all the profile's data, nationality and work permit included, its documents and its photo. Hand it to a candidate who asks for access to their data. + +## Automatic analysis + +With automatic analysis, the CV is sent to Anthropic, the publisher of Claude, for analysis and translation. Mention it in the information given to candidates. + +# Archiving, updating, deleting + +**File** tab, **Manage** section. + +## Archiving + +**Archive the profile** takes the profile off the board, the dashboard, the follow-up page and the everyday search. Its data, its documents and its retention date stay. + +To find it again: **Profiles** menu, **Archived** filter set to "Only". On the profile page, **Unarchive** puts it back in its column. The status of an archived profile does not change. + +## Updating from a new analysis + +A candidate sends a more recent CV: upload the new JSON and, if you have them, the new CV and the new report. + +- Replaced: experience, technologies, languages, education, keywords from the analysis. +- Kept: status, comments, interviews, follow-ups, documents, history, keywords added by hand. + +## Deleting + +Only the account manager deletes a profile. If a profile has to go, ask them. + +**Delete profile** moves the profile to the trash. It disappears at once from the board, the profiles, search, client requests, follow-up and exports. For 30 days, the account manager can restore it with all its data. + +After 30 days, Vivier erases the profile, its documents, its photo, its comments, its interviews, its follow-ups, its keywords and its history. The activity log keeps one line with the profile's reference, without the name. + +# Activity log + +The account manager sees the full log: **Manage** menu, **Activity log**. The last 300 actions, across all profiles, filterable by account. + +Each line gives the date, the account, the profile, the action and its detail: old and new value of a field, status change, document added, CV exported. + +On each profile, the history shows everyone the same lines for that profile only: **File** tab. + +# My account and language + +Click your initials, top right. + +- **My account**: your identity, two-step verification, password, sessions. +- **Language**: French or English. The choice is kept for your account. +- **Sign out**. + +In **My account**: + +| Action | Effect | +|---|---| +| Generate new backup codes | Ten new codes. The old ones stop working | +| Set up another app | For a new phone. The old one stops working | +| Change password | Your other sessions close | +| Sign out my other sessions | Closes everything except the current session | +| Your latest sign-ins | Date, browser and address of your latest sign-ins | + +The interface language changes how profiles show: they appear translated. Comments and interview reports stay as they were written. + +# Frequently asked questions + +## The automatic analysis failed + +The message gives the cause. The most common ones: + +| Message | What to do | +|---|---| +| Time limit exceeded | Run it again. If it keeps failing, use the files already analysed | +| The document is not a CV | Check the uploaded file | +| API key refused | Tell the account manager | +| Too many requests or credit used up | Try again later, or tell the account manager | + +## The seniority does not match the CV + +Vivier calculates it from the dates of each position. Internships are excluded, overlapping periods are counted once. A date without a month counts as January. + +| Experience | Level | +|---|---| +| Less than 4 years | Junior | +| 4 to less than 8 years | Mid-level | +| 8 to less than 12 years | Senior | +| 12 years and more | Expert | + +If the dates are wrong, fix the JSON and update the profile from a new analysis. + +## The photo in the CV was not found + +Vivier keeps the image that looks like a portrait. A cut-out portrait on a wide white background may be discarded. Upload the photo by hand: **File** tab, **Photo** section. + +## A profile has disappeared from the board + +It is archived or deleted. Look for it in **Profiles** with the **Archived** filter set to "Included". If it is not there, ask the account manager: the activity log says who moved it to the trash and when. For 30 days, they can restore it. + +## A card will not move + +The change was not saved: a red message appears and the card goes back to its place. Check your connection and reload the page. + +## My two-step code is rejected + +Check that the phone sets its time automatically, then enter the code shown now. A code works only once: wait for the next one. Otherwise, use a backup code. + +## I lost my phone + +Sign in with a backup code, then **My account**, **Set up another app**. No backup codes left: the account manager resets your two-step verification. + +## A profile stays in French while I work in English + +Its translation is not done yet. The profile banner says so. Click **Translate now**, or wait: the translation runs in the background. + +## I cannot find a profile with the search + +Remove a word: every word must appear on the profile. Check the **Archived** filter. If the search seems empty after a Vivier update, the account manager runs the indexing again. diff --git a/aide/guide.fr.md b/aide/guide.fr.md new file mode 100644 index 0000000..26c45b8 --- /dev/null +++ b/aide/guide.fr.md @@ -0,0 +1,596 @@ +# Vivier en bref + +Vivier suit les CV reçus par IT Club, du premier contact à l'embauche. + +- Chaque CV devient un **profil** : identité, expériences, technologies, langues, séniorité. +- Les profils avancent dans un **Kanban** à six colonnes. +- Sur chaque profil, vous notez les entretiens, les relances, les commentaires et les documents. +- Les **besoins clients** décrivent les missions à pourvoir. Vivier propose les profils qui y répondent. +- Vivier génère un **CV au format IT Club** et un **CV Europass**, nominatifs ou anonymisés, en français, anglais ou allemand. +- Chaque profil s'affiche dans la langue de l'interface, traduit automatiquement. +- Chaque action est tracée dans un journal. + +Tous les comptes lisent et modifient tous les profils. Le gestionnaire des comptes a en plus le menu **Gestion** : comptes, journal, conservation, corbeille, sauvegarde. + +Le guide **Sécurité** explique comment Vivier protège vos accès et ce que vous pouvez faire de votre côté. + +# Se connecter + +1. Ouvrez l'adresse de Vivier dans votre navigateur. +2. Saisissez votre identifiant et votre mot de passe, puis **Continuer**. +3. Saisissez le code à 6 chiffres affiché par l'application d'authentification de votre téléphone. + +À savoir : + +- Un mot de passe compte au moins 12 caractères. Vivier refuse un mot de passe connu dans des fuites de données. +- Après 5 essais infructueux, la connexion se bloque 15 minutes pour cet identifiant, puis plus longtemps. +- La session se ferme après 2 heures sans activité, et au plus tard 12 heures après la connexion. Vivier vous ramène ensuite à la page que vous aviez ouverte. +- Téléphone pas sous la main : saisissez un code de secours dans le champ du code. +- Mot de passe oublié ou téléphone perdu sans code de secours : adressez-vous au gestionnaire des comptes. + +Le guide **Sécurité** détaille chaque point. + +## Obtenir un compte + +Vivier n'a pas d'inscription libre. + +1. Le gestionnaire des comptes vous envoie un lien d'invitation. Il sert une fois et expire après 48 heures. +2. Ouvrez le lien. Choisissez votre identifiant et votre mot de passe. +3. Activez la double authentification : installez une application d'authentification sur votre téléphone, scannez le QR code, saisissez le code affiché. +4. Gardez les dix codes de secours que Vivier vous donne. Ils ne s'affichent qu'une fois. + +Le gestionnaire peut aussi créer votre compte avec un mot de passe provisoire. À la première connexion, vous choisissez le vôtre, puis vous activez la double authentification. + +# L'écran et le menu + +L'en-tête noir reste en haut de chaque page. + +| Élément | Rôle | +|---|---| +| Kanban | Les profils actifs, rangés par état | +| Profils | La liste complète et la recherche | +| Besoins | Les missions à pourvoir chez les clients | +| Suivi | Les relances à faire et les entretiens à venir | +| Tableau de bord | Les chiffres du vivier | +| Gestion | Pour le gestionnaire seulement : Conservation, Journal, Connexions, Comptes, Corbeille, Sauvegarde, Erreurs | +| Champ de recherche | Cherche dans toutes les fiches | +| Importer | Ajoute un ou plusieurs CV | +| Point d'interrogation | Ouvre cette aide | +| Vos initiales | Mon compte, langue de l'interface, déconnexion | + +Une pastille dorée sur **Suivi** compte les relances arrivées à échéance. Pour le gestionnaire, une pastille sur **Gestion** additionne les profils dont la conservation est dépassée et les demandes de compte à valider. Le sous-menu donne le détail. + +Sur un écran étroit, le bouton **Menu** déplie ces rubriques. + +# Importer un CV + +Bouton **Importer**, dans l'en-tête. Deux voies mènent au même écran de relecture. + +## Analyse automatique + +1. Dans la carte **Analyse automatique**, choisissez le CV : PDF, DOCX, ODT, MD ou TXT. +2. Cliquez sur **Analyser le CV**. Comptez une à deux minutes, sans fermer la page. +3. Vivier affiche l'écran de relecture. + +Cette voie demande une clé d'API Claude, réglée par le gestionnaire. Sans clé, la carte indique « Non configurée ». + +Un fichier DOC ou RTF n'est pas lu par cette voie : convertissez-le en PDF. + +## Fichiers déjà analysés + +Vous faites l'analyse vous-même dans un projet Claude, puis vous chargez le résultat. + +1. Dans le projet Claude d'analyse, joignez le CV et écrivez : « analyse ce CV pour Vivier ». +2. Claude produit deux fichiers : le profil structuré (`.json`) et le rapport détaillé (`.md`). Téléchargez-les. +3. Dans Vivier, carte **Fichiers déjà analysés**, chargez le JSON, le CV d'origine et le rapport. Le rapport est facultatif. +4. Cliquez sur **Contrôler le profil**. + +Si le JSON n'est pas conforme, Vivier refuse l'import et liste chaque point à corriger. + +## Relecture avant enregistrement + +L'écran de relecture montre ce que Vivier va enregistrer. + +- Vérifiez l'identité, l'email, le téléphone et les dates des expériences. +- La **séniorité** est recalculée par Vivier à partir des dates. Si elle diffère de l'analyse, un message le signale. +- Les **points d'attention** relevés par l'analyse s'affichent : trous dans le parcours, dates floues, informations manquantes. +- Si le CV contient une **photo**, elle apparaît avec la case **Garder cette photo**. Décochez-la pour ne pas la garder. + +Cliquez sur **Enregistrer le profil**. Le profil arrive dans la colonne **Reçu**. **Annuler** abandonne l'import et efface les fichiers chargés. + +## Doublons + +Vivier compare le profil aux fiches existantes, archivées comprises. Un profil à la corbeille compte aussi : il est signalé par sa référence, et le gestionnaire des comptes peut le restaurer ou l'effacer. + +| Ressemblance | Effet | +|---|---| +| Même adresse email | L'enregistrement est refusé. Mettez à jour la fiche existante. | +| Même téléphone | Avertissement. Une case confirme qu'il s'agit d'une autre personne. | +| Même prénom et même nom | Avertissement. Une case confirme qu'il s'agit d'une autre personne. | + +La comparaison des téléphones ignore l'indicatif, les espaces et la ponctuation. Celle des noms ignore la casse, les accents et l'ordre. + +## Importer plusieurs CV + +Page Importer, bouton **Importer plusieurs CV**. Le lot contient 20 CV au plus. + +**CV à analyser** : sélectionnez plusieurs fichiers, puis **Déposer les CV**. Vivier les analyse l'un après l'autre. Gardez la page ouverte. Si vous la fermez, les analyses reprennent à sa réouverture. + +**Profils déjà analysés** : sélectionnez ensemble les JSON, les CV et les rapports, puis **Déposer les fichiers**. Vivier associe chaque JSON au CV que nomme son champ `source.fichier_origine`. Un rapport est associé quand son nom reprend celui du JSON ou le nom du candidat. + +Chaque ligne du lot porte un état : + +| État | Suite | +|---|---| +| En attente | L'analyse va démarrer | +| Prêt à relire | **Relire** ouvre l'écran de relecture | +| Erreur | Le motif s'affiche. **Relancer l'analyse** ou **Retirer** | + +**Enregistrer les profils prêts** enregistre d'un coup ceux qui ne ressemblent à aucun profil existant. Les autres restent dans le lot, à relire un par un. Un CV laissé dans le lot est retiré après 7 jours. + +# Le Kanban et les états + +Le Kanban montre les profils actifs en six colonnes. + +| État | Sens | +|---|---| +| Reçu | Le CV vient d'entrer | +| En cours | Le profil est à l'étude, avant tout entretien | +| Entretiens en cours | Un ou plusieurs entretiens sont prévus ou passés | +| Validé | IT Club retient le profil | +| Embauché | Le candidat a rejoint IT Club | +| Non retenu | Le profil est écarté. Un motif est obligatoire | + +## Déplacer une carte + +- Glissez la carte d'une colonne à l'autre. Le changement s'enregistre aussitôt. +- Le passage en **Non retenu** ouvre une fenêtre qui demande le motif. +- Dans une même colonne, glissez une carte pour changer l'ordre. +- Sur écran tactile, maintenez l'appui pour saisir la carte. + +Le statut se change aussi sur la fiche du profil. + +## Lire une carte + +Une carte affiche la photo ou les initiales, le nom, le titre, la séniorité, les années d'expérience et les trois technologies principales. S'y ajoutent, quand ils existent : + +- la date du prochain entretien +- le nombre de relances à faire +- un repère rouge quand le titre de séjour expire dans moins de 2 mois, ou a expiré +- le motif, dans la colonne Non retenu. + +Les barres empilées à gauche du nom d'une colonne montrent l'avancement : une barre pour Reçu, cinq pour Embauché. + +## Filtrer + +Le champ **Filtrer les cartes** garde les cartes qui contiennent les mots saisis : nom, titre ou technologie. La liste **Séniorité** garde un niveau. Le filtre agit à l'écran seulement. + +# La fiche d'un profil + +Cliquez sur un nom pour ouvrir sa fiche. Elle se compose d'un bandeau et de quatre onglets. + +## Le bandeau + +Le bandeau reste en haut de la fiche, quel que soit l'onglet. + +- La photo, le nom, le titre, la séniorité et les années d'expérience. +- **CV d'origine** : le fichier reçu du candidat. +- **CV IT Club** et **CV Europass** : choisissez **Nominatif** ou **Anonymisé** pour ouvrir l'aperçu du CV généré. +- **Modifier** : la fiche en six blocs, décrits plus bas. Vivier enregistre le nom de famille en capitales. +- **Statut** : choisissez l'état puis **Mettre à jour**. +- La disponibilité, le TJM souhaité et la ville du candidat. +- Le motif, si le profil est **Non retenu**. + +Une ligne **À faire** apparaît quand une action attend : relance en retard, avis d'entretien à donner, prochain entretien, titre de séjour à renouveler, conservation dépassée. Un clic ouvre l'onglet et la section concernés. Les points rouges signalent un retard. + +## Modifier la fiche + +Bouton **Modifier**. Six blocs : + +| Bloc | Contenu | +|---|---| +| Identité et CV | Prénom, nom, titre, synthèse. Repris sur les CV | +| Coordonnées | Email, téléphone, adresse. Seuls la ville et le pays figurent sur le CV nominatif | +| Nationalité et permis de travail | Jusqu'à trois nationalités, le permis de travail, sa date d'expiration. Sur aucun CV | +| Disponibilité | Un choix dans une liste, une date si besoin, une note libre | +| Données commerciales | TJM, salaire, mobilité, télétravail. Sur aucun CV | +| CV Europass | Bénévolat et permis de conduire, repris sur le CV Europass seulement | + +**Disponibilité.** Choisissez Immédiate, Sous 1 mois, Sous 2 mois, Sous 3 mois, Date précise ou À préciser. « Date précise » demande la date. « Sous N mois » calcule une date estimée, utilisée pour le rapprochement avec les besoins clients. La note libre garde une précision interne, par exemple un préavis à négocier. + +**Nationalité et permis de travail.** Choisissez la nationalité dans la liste : tapez les premières lettres du pays pour aller plus vite. Une nationalité de l'Union européenne, de l'Espace économique européen ou suisse n'a pas besoin de permis de travail au Luxembourg : Vivier l'indique et masque les champs du permis. Sinon, décrivez le titre en texte libre et indiquez sa date d'expiration. Vivier prévoit alors une relance 2 mois avant, visible dans **Suivi**. Ne chargez pas de copie du titre dans Vivier. + +L'analyse reprend la nationalité quand le CV l'écrit. Le permis de travail se saisit toujours à la main. + +## Les quatre onglets + +Les onglets restent collés sous l'en-tête. Au clavier, les flèches passent d'un onglet à l'autre. + +| Onglet | Contenu | +|---|---| +| Profil | Synthèse, points d'attention, expériences, technologies, formation et certifications, coordonnées, nationalité et permis de travail, langues, bénévolat | +| Suivi | Entretiens, commentaires, relances | +| Missions | Besoins clients et données commerciales | +| Dossier | Documents, mots-clés et classement, historique, données personnelles, photo, gestion | + +La fiche s'ouvre sur l'onglet **Profil**. Après un enregistrement, Vivier revient sur l'onglet où vous étiez : un commentaire publié vous ramène dans **Suivi**, un document ajouté dans **Dossier**. Les liens de la page **Suivi** ouvrent la fiche sur l'onglet **Suivi**. + +Une pastille rouge sur **Suivi** compte les relances en retard et les avis d'entretien à donner. Un point d'exclamation sur **Dossier** signale une conservation dépassée. Le petit nombre gris à côté d'un onglet compte ce qu'il contient. + +Dans l'onglet **Profil**, les trois expériences les plus récentes et les dix technologies les plus pratiquées s'affichent. Le lien **Voir…** placé sous la liste déplie le reste. Les formulaires d'ajout (entretien, relance, document, photo) s'ouvrent d'un clic sur leur titre. + +## Commentaires + +Écrivez dans **Nouveau commentaire**, puis **Publier**. Un commentaire porte votre nom et la date. Il ne se modifie pas. + +## Documents + +- Le CV d'origine et le rapport sont attachés à l'import. +- **Ajouter un document** : choisissez le fichier et son type. Taille maximale : 10 Mo. +- Formats acceptés : PDF, DOCX, DOC, ODT, RTF, MD, TXT, PNG, JPG, XLSX. +- **Lire** ouvre un rapport `.md` ou `.txt` dans Vivier. + +## Mots-clés + +Les mots-clés viennent de l'analyse. Ils améliorent la recherche. + +- Ajoutez un mot avec son type, puis **Ajouter**. Un mot ajouté à la main s'affiche sur fond clair. +- La croix retire un mot. +- Les mots ajoutés à la main survivent à une mise à jour de l'analyse. + +## Photo + +La photo sert à reconnaître le candidat dans Vivier : fiche, Kanban, résultats de recherche. Elle ne figure sur aucun CV exporté. Vivier ne l'analyse pas. + +## Données commerciales + +Bouton **Modifier**, bloc **Données commerciales** : TJM souhaité, salaire annuel brut souhaité, mobilité, télétravail. + +Ces données servent au rapprochement avec les besoins clients et à l'export CSV. Elles ne figurent sur aucun CV exporté. La disponibilité a son propre bloc. + +# Entretiens, relances et suivi + +## Noter un entretien + +Sur la fiche, onglet **Suivi**, section **Entretiens**, **Ajouter un entretien**. + +1. Saisissez la date. L'heure est facultative. +2. Choisissez le type : téléphone, visioconférence, sur place, entretien technique, entretien client. +3. Indiquez qui mène l'entretien et, s'il y en a un, le besoin client concerné. +4. Cliquez sur **Enregistrer l'entretien**. + +Pour un profil Reçu ou En cours, la case **Passer le profil en « Entretiens en cours »** est cochée : le profil change de colonne en même temps. + +Après l'entretien, ouvrez **Donner l'avis et le compte rendu** : avis favorable, réservé ou défavorable, puis le compte rendu. Un entretien passé sans avis porte la mention « avis à donner ». + +## Prévoir une relance + +Sur la fiche, onglet **Suivi**, section **Relances**, ouvrez **Prévoir une relance** : écrivez ce qu'il faut faire, choisissez l'échéance, puis **Prévoir la relance**. + +Quand c'est fait, cliquez sur **Marquer comme faite**. La relance reste sur la fiche, datée et signée. + +## La page Suivi + +Menu **Suivi**. Le titre compte les relances prévues et, en rouge, celles à faire aujourd'hui ou en retard. Trois listes : + +- **Relances** : les plus anciennes d'abord. Une relance arrivée à échéance s'affiche en rouge. +- **Entretiens des 30 prochains jours**. +- **Entretiens passés sans avis** : à compléter. + +Les profils archivés n'y figurent pas. + +# Besoins clients et rapprochement + +Menu **Besoins**. Un besoin décrit une mission à pourvoir. + +## Créer un besoin + +Ouvrez **Nouveau besoin**. Le client et l'intitulé du poste sont obligatoires. Renseignez ce que vous savez : + +- les technologies demandées, séparées par des virgules +- la séniorité minimale +- les langues demandées +- le lieu et le télétravail +- la date de début et le budget journalier. + +Plus la demande est précise, plus le rapprochement l'est. + +## Lire le rapprochement + +La fiche d'un besoin **ouvert** liste les profils du vivier qui y répondent, les mieux notés d'abord. + +- La note, sur 100, s'affiche à droite de chaque profil. +- Les technologies trouvées sur la fiche s'affichent sur fond doré, avec les années de pratique. +- Les technologies manquantes s'affichent en pointillé. +- Les écarts s'affichent en rouge : séniorité inférieure, langue absente, disponibilité trop tardive, TJM au-dessus du budget. + +| Critère | Points | Règle | +|---|---|---| +| Technologies | 55 | Part des technologies demandées présentes sur la fiche. Une technologie présente vaut 60 % de sa part, 100 % à partir de 5 ans de pratique | +| Intitulé | 15 | Part des mots de l'intitulé présents sur la fiche | +| Séniorité | 10 | 10 si le niveau est atteint, 4 un niveau en dessous, 0 sinon | +| Langues | 10 | Part des langues demandées présentes sur la fiche | +| Disponibilité | 5 | 5 si le profil est disponible avant le début, 0 après | +| Tarif | 5 | 5 si le TJM tient dans le budget, 2 jusqu'à 10 % au-dessus, 0 au-delà | + +Un critère absent du besoin donne tous ses points. Une donnée absente de la fiche donne la moitié. Les profils archivés ou non retenus sont exclus. + +La note classe les profils. Elle ne remplace pas la lecture de la fiche. + +## Proposer un profil + +- Sur la fiche du besoin : **Proposer**, en face du profil. +- Sur la fiche du profil : onglet **Missions**, section **Besoins clients**, choisissez le besoin, puis **Proposer**. + +Un profil proposé suit quatre états : + +| État | Sens | +|---|---| +| À présenter | Le profil est retenu en interne | +| Présenté au client | Le CV est parti chez le client | +| Retenu par le client | Le client veut ce profil | +| Écarté | Le profil ne convient pas pour ce besoin | + +**Mettre à jour**, sous le nom du profil, change l'état et ajoute une note. + +## Clore un besoin + +Changez le **Statut du besoin** : Ouvert, Pourvu ou Fermé. Seul un besoin ouvert affiche le rapprochement. Supprimer un besoin efface la liste des profils proposés. Les profils restent. + +# Rechercher un profil + +Le champ de l'en-tête et le menu **Profils** interrogent toutes les fiches. + +## Écrire une recherche + +- Tous les mots saisis doivent figurer sur la fiche. +- Accents, majuscules et pluriels sont ignorés. +- Un mot de trois lettres ou plus trouve aussi les mots qui commencent par lui : `dev` trouve « développeur ». +- `senior` garde les profils Confirmé et Expert. `junior`, `confirmé`, `expert` filtrent de même. +- Une expression entre guillemets est cherchée telle quelle : `"gestion documentaire"`. +- Les symboles des technologies sont gardés : C#, C++, .NET, Node.js, CI/CD. + +| Recherche | Résultat | +|---|---| +| `développeur java senior` | Profils Confirmé ou Expert qui portent « développeur » et « java » | +| `XML` | Profils qui citent XML, où que ce soit | +| `angular luxembourg` | Profils qui portent les deux mots | +| `"product owner"` | Profils qui portent l'expression exacte | + +## Classement et filtres + +Les résultats sont classés par pertinence. Un mot pèse plus dans le nom, le titre ou les technologies que dans une description de poste. Les technologies et les mots-clés trouvés s'affichent sur fond doré. + +Six filtres : statut, séniorité, langue, nationalité, titre de séjour, profils archivés. Les profils archivés sont masqués par défaut. Un filtre changé relance la recherche aussitôt. Sur téléphone, le bouton **Filtres** les affiche ou les replie. + +| Filtre | Valeurs | +|---|---| +| Nationalité | UE, EEE ou Suisse (pas de permis de travail), Hors UE, Non renseignée | +| Titre de séjour | Expiré ou expire sous 2 mois | + +La recherche trouve un profil par ses textes d'origine comme par leur traduction : `developer` et `développeur` donnent les mêmes profils. + +**Exporter en CSV** télécharge les profils affichés. Ce bouton est réservé au gestionnaire des comptes. + +# Le CV au format IT Club + +Sur une fiche, **CV IT Club** et **CV anonymisé** ouvrent un aperçu au format A4. + +## Ce que contient le CV + +- Le nom, le titre, la séniorité, les langues, la disponibilité. +- La synthèse, les compétences techniques, les expériences avec leur projet, leur mission et leur charge, la formation, les certifications. +- Les mentions d'IT Club en pied de page. + +Le CV ne porte aucune coordonnée personnelle : le contact passe par IT Club. Il ne porte ni photo, ni donnée commerciale, ni nationalité, ni permis de travail. + +La **version anonymisée** remplace le nom par une référence, par exemple « Profil ITC-0001 », et masque la localisation. Les noms d'employeurs et de clients restent visibles. + +## Exporter + +| Bouton | Résultat | +|---|---| +| Télécharger en Word | Un fichier `.docx` modifiable | +| Enregistrer en PDF | L'impression du navigateur. Choisissez « Enregistrer au format PDF » et décochez « En-têtes et pieds de page » | + +Chaque export est tracé dans l'historique du profil. + +## Changer la langue du CV + +Dans la barre de l'aperçu, choisissez FR, EN ou DE. L'aperçu s'ouvre dans la langue du CV d'origine. Avec l'interface en anglais, il s'ouvre en anglais. + +- Les intitulés du CV suivent la langue choisie. +- Le contenu reste dans la langue du CV d'origine tant qu'il n'est pas traduit. L'aperçu le signale. + +Pour traduire le contenu : + +- **Traduire le contenu**, si l'analyse automatique est configurée. Relisez le résultat avant d'envoyer le CV. +- **Traduire par fichier**, sinon : téléchargez le fichier, déposez-le dans le projet Claude d'analyse, puis rechargez le fichier traduit. + +La traduction est gardée. Elle sert aussi à l'affichage de la fiche et au CV Europass. Si la fiche change ensuite, Vivier refait la traduction en arrière-plan. En attendant, le contenu d'origine revient. Les noms de technologies, d'entreprises et de clients ne sont pas traduits. + +# Le CV Europass + +Sur une fiche, **CV Europass**, puis **Nominatif** ou **Anonymisé**. L'aperçu reprend la présentation du CV Europass d'IT Club : bandeau bleu, logo Europass, rubriques À propos de moi, Éducation et formation, Expérience professionnelle, Compétences linguistiques, Compétences, Bénévolat, Permis de conduire. + +## Ce que contient le CV + +- Les expériences : employeur, lieu, dates, poste, client, projet, mission, technologies, charge, puis les réalisations. +- Les formations avec leurs dates et leur lieu, quand ils sont connus. +- Les langues : la grille européenne (compréhension, expression orale, écrit) quand le CV d'origine la donne, sinon une ligne par langue, par exemple « Anglais : C1 ». +- Les compétences, groupées : certifications, technique, bases de données, logiciels et outils, cloud, méthodes. +- Le bénévolat et le permis de conduire, seulement s'ils sont remplis. + +Le CV Europass ne porte ni date de naissance, ni nationalité, ni permis de travail, ni donnée commerciale. + +## Les réglages de l'aperçu + +| Bouton | Effet | +|---|---| +| Nominatif, Anonymisé | La version anonymisée remplace le nom par la référence et retire photo et coordonnées | +| Coordonnées | Montre ou retire l'email, le téléphone, le profil LinkedIn et l'adresse | +| Photo | Ajoute la photo du profil. Retirée par défaut | +| FR, EN, DE | La langue du CV, comme pour le CV IT Club | +| Télécharger en Word | Un fichier `.docx` modifiable | +| Enregistrer en PDF | L'impression du navigateur, au format A4 | + +Chaque export est tracé dans l'historique du profil. + +# Profils dans votre langue + +Vivier affiche chaque profil dans la langue de l'interface. En anglais, vous lisez le titre, la synthèse, les expériences, les formations, le bénévolat et les points d'attention en anglais. + +- La traduction se fait automatiquement après l'import, en arrière-plan, tant qu'une page de Vivier reste ouverte. +- Un profil pas encore traduit s'affiche dans sa langue d'origine, avec un bandeau. **Traduire maintenant** lance la traduction sans attendre. +- Sous un profil traduit, **Voir l'original** affiche le texte d'origine. +- Les commentaires, les comptes rendus d'entretien, les besoins clients et le permis de travail restent dans la langue de leur auteur. +- Les noms de technologies, d'entreprises, de clients et de projets ne sont pas traduits. + +La traduction est automatique. Relisez-la avant d'envoyer un CV. + +# Tableau de bord et export CSV + +Menu **Tableau de bord**. + +- Le bandeau noir donne les chiffres clés : profils dans le vivier, reçus sur 30 jours, en cours de parcours, embauchés, entretiens sur 7 jours, relances à faire, besoins ouverts, conservation à traiter. +- **Profils par statut** et **Séniorité** : un clic sur une ligne ouvre la liste correspondante. +- **Temps moyen passé à chaque étape** : durée entre l'entrée dans un état et sa sortie. +- **Technologies les plus présentes** : un clic lance la recherche. + +Les profils archivés sont comptés à part. + +L'export CSV (menu Profils, **Exporter en CSV**) contient une ligne par profil affiché, données commerciales, nationalité et permis de travail compris. Le séparateur est le point-virgule : Excel ouvre le fichier directement. Cet export, qui sort tout le vivier d'un coup, est réservé au gestionnaire des comptes. + +# Données personnelles et conservation + +Vivier contient des données personnelles. Trois outils aident à respecter le RGPD. + +## L'accord du candidat + +Sur la fiche, onglet **Dossier**, section **Données personnelles**, **Enregistrer l'accord du candidat** : notez la date à laquelle le candidat a accepté que IT Club conserve son CV, et la façon dont il l'a dit. L'accord se modifie et se retire. + +## La durée de conservation + +Chaque profil porte une échéance, fixée à l'import : 24 mois par défaut. + +Le gestionnaire des comptes suit les échéances : menu **Gestion**, **Conservation**. La page liste les profils dont l'échéance est dépassée ou arrive dans 60 jours. + +- **Prolonger** repousse l'échéance, si le candidat a confirmé son accord. +- **Supprimer** met le profil à la corbeille. + +Sur la fiche, la ligne **À faire** signale à tous une conservation dépassée. Prévenez le gestionnaire si le candidat a renouvelé son accord. + +## L'export des données d'un candidat + +Sur la fiche, onglet **Dossier**, section **Données personnelles**, **Exporter les données du candidat**. Vivier télécharge un zip avec toutes les données du profil, nationalité et permis de travail compris, ses documents et sa photo. Remettez-le au candidat qui demande l'accès à ses données. + +## L'analyse automatique + +Avec l'analyse automatique, le CV est transmis à Anthropic, l'éditeur de Claude, pour l'analyse et pour la traduction. Mentionnez-le dans l'information donnée aux candidats. + +# Archiver, mettre à jour, supprimer + +Onglet **Dossier**, section **Gestion**. + +## Archiver + +**Archiver le profil** sort le profil du Kanban, du tableau de bord, du suivi et de la recherche courante. Ses données, ses documents et son échéance de conservation restent. + +Pour le retrouver : menu **Profils**, filtre **Archivés** sur « Seulement ». Sur la fiche, **Désarchiver** le remet dans sa colonne. Le statut d'un profil archivé ne change pas. + +## Mettre à jour depuis une nouvelle analyse + +Un candidat envoie un CV plus récent : chargez le nouveau JSON, et si vous les avez, le nouveau CV et le nouveau rapport. + +- Remplacés : expériences, technologies, langues, formation, mots-clés issus de l'analyse. +- Gardés : statut, commentaires, entretiens, relances, documents, historique, mots-clés ajoutés à la main. + +## Supprimer + +Seul le gestionnaire des comptes supprime un profil. Si un profil doit partir, demandez-le-lui. + +**Supprimer le profil** met la fiche à la corbeille. Elle disparaît aussitôt du Kanban, des profils, de la recherche, des besoins, du suivi et des exports. Pendant 30 jours, le gestionnaire peut la restaurer avec toutes ses données. + +Après 30 jours, Vivier efface la fiche, ses documents, sa photo, ses commentaires, ses entretiens, ses relances, ses mots-clés et son historique. Le journal garde une ligne avec la référence du profil, sans son nom. + +# Journal + +Le gestionnaire des comptes voit le journal complet : menu **Gestion**, **Journal**. Les 300 dernières actions, tous profils confondus, filtrables par compte. + +Chaque ligne donne la date, le compte, le profil, l'action et son détail : ancienne et nouvelle valeur d'un champ, changement de statut, document ajouté, CV exporté. + +Sur chaque fiche, l'historique montre à tous les mêmes lignes pour ce seul profil : onglet **Dossier**. + +# Mon compte et langue + +Cliquez sur vos initiales, en haut à droite. + +- **Mon compte** : votre identité, la double authentification, le mot de passe, les sessions. +- **Langue** : français ou anglais. Le choix est gardé pour votre compte. +- **Déconnexion**. + +Dans **Mon compte** : + +| Action | Effet | +|---|---| +| Générer de nouveaux codes de secours | Dix nouveaux codes. Les anciens cessent de marcher | +| Configurer une autre application | Pour un nouveau téléphone. L'ancien cesse de marcher | +| Changer le mot de passe | Vos autres sessions se ferment | +| Déconnecter mes autres sessions | Ferme tout sauf la session en cours | +| Vos dernières connexions | Date, navigateur et adresse de vos dernières connexions | + +La langue de l'interface change l'affichage des profils : ils s'affichent traduits. Les commentaires et les comptes rendus restent tels qu'ils ont été écrits. + +# Questions fréquentes + +## L'analyse automatique a échoué + +Le message donne la cause. Les plus courantes : + +| Message | Que faire | +|---|---| +| Délai dépassé | Relancez. Si l'échec persiste, passez par les fichiers déjà analysés | +| Le document n'est pas un CV | Vérifiez le fichier chargé | +| Clé d'API refusée | Prévenez le gestionnaire des comptes | +| Trop de demandes ou crédit épuisé | Réessayez plus tard, ou prévenez le gestionnaire | + +## La séniorité ne correspond pas au CV + +Vivier la calcule à partir des dates des expériences. Stages exclus, périodes qui se chevauchent comptées une fois. Une date sans mois vaut janvier. + +| Expérience | Niveau | +|---|---| +| Moins de 4 ans | Junior | +| De 4 à moins de 8 ans | Intermédiaire | +| De 8 à moins de 12 ans | Confirmé | +| 12 ans et plus | Expert | + +Si les dates sont fausses, corrigez le JSON et mettez la fiche à jour depuis une nouvelle analyse. + +## La photo du CV n'a pas été trouvée + +Vivier garde l'image qui ressemble à un portrait. Un portrait détouré sur un large fond blanc peut être écarté. Chargez la photo à la main : onglet **Dossier**, section **Photo**. + +## Un profil a disparu du Kanban + +Il est archivé ou supprimé. Cherchez-le dans **Profils** avec le filtre **Archivés** sur « Inclus ». S'il n'y est pas, demandez au gestionnaire des comptes : le journal indique qui l'a mis à la corbeille et quand. Pendant 30 jours, il peut le restaurer. + +## Une carte ne se déplace pas + +Le changement n'a pas été enregistré : un message rouge s'affiche et la carte revient à sa place. Vérifiez votre connexion et rechargez la page. + +## Mon code de double authentification est refusé + +Vérifiez que l'heure du téléphone se règle automatiquement, puis saisissez le code affiché maintenant. Un code ne sert qu'une fois : attendez le suivant. Sinon, utilisez un code de secours. + +## J'ai perdu mon téléphone + +Connectez-vous avec un code de secours, puis **Mon compte**, **Configurer une autre application**. Plus de code de secours : le gestionnaire des comptes remet votre double authentification à zéro. + +## Un profil reste en français alors que je suis en anglais + +Sa traduction n'est pas encore faite. Le bandeau du profil le signale. Cliquez sur **Traduire maintenant**, ou attendez : la traduction se fait en arrière-plan. + +## Je ne trouve pas un profil par la recherche + +Retirez un mot : tous les mots doivent figurer sur la fiche. Vérifiez le filtre **Archivés**. Si la recherche semble vide après une mise à jour de Vivier, le gestionnaire relance l'indexation. diff --git a/aide/securite.en.md b/aide/securite.en.md new file mode 100644 index 0000000..9865083 --- /dev/null +++ b/aide/securite.en.md @@ -0,0 +1,161 @@ +# Why Vivier protects its access + +Vivier holds the CVs, contact details and exchanges of hundreds of candidates. This is personal data. IT Club is accountable for it to the candidates and under the law (GDPR). + +A password alone is no longer enough: it can be guessed, stolen through a fake email, or leaked by another website. Vivier adds several protections. Each one blocks a specific attack. + +| Protection | What it prevents | +|---|---| +| Access by invitation | A stranger creating an account | +| Two-step verification | A stolen password being enough to get in | +| Password checks | Using a password already published after a breach | +| Blocking failed attempts | A program trying thousands of passwords | +| Limited sessions | A forgotten computer staying signed in for days | +| Sign-in log | An intrusion going unnoticed | +| Account manager rights | A single compromised account exporting the whole pool | + +This page applies to every account. You can also read it without signing in, from the sign-in page. + +# Getting an account: the invitation + +Vivier has no open sign-up. + +1. The Vivier account manager creates an invitation link for you. +2. They send it to you, by email or internal chat. +3. You open the link and choose your username and password. +4. You set up two-step verification. Your account is ready. + +The link works once and expires after 48 hours. Link expired: ask for a new one. + +The account manager can also create your account with a temporary password. You then change it at first sign-in. After that, nobody but you knows your password. + +# Two-step verification + +Each time you sign in, Vivier asks for two proofs: + +1. your password, something you know +2. a 6-digit code shown on your phone, something you have + +A thief who knows your password stays out without your phone. + +## Setting it up + +At first sign-in, Vivier guides you through three steps. + +1. Install an authenticator app: Google Authenticator, Microsoft Authenticator, or the password manager you already use (Bitwarden, 1Password, Apple Passwords). +2. In the app, add an account and scan the QR code Vivier shows. Without a camera, type the key written under the QR code. +3. Enter the 6-digit code the app shows. + +The code changes every 30 seconds. The app works without a network. + +## Backup codes + +Vivier then gives you ten backup codes, like `abcd-efgh`. They replace the phone code if you lose or forget your phone. + +- Each code works once. +- They are shown only once. Copy them into your password manager, or print them and store the sheet. +- Running low? **My account**, **Generate new backup codes**. The old ones stop working. + +## Changing phones + +**My account**, **Set up another app**. Scan the new QR code with the new phone. The old one stops working, your other sessions close and you get ten new backup codes. + +## Lost phone + +1. Sign in with a backup code, in the code field. +2. Set up another app right away from **My account**. + +No backup codes left: the account manager resets your two-step verification, after checking it is really you. You set it up again at your next sign-in. + +## The code is rejected + +- Check that your phone sets its time automatically. A gap of more than a minute breaks the code. +- Enter the code shown now, not the previous one. +- A code already used does not work twice: wait for the next one. + +# Your password + +- At least 12 characters. A phrase of several words is easier to remember than a jumble of symbols: `four cats on the blue roof`. +- Vivier rejects passwords found in known data breaches. To check, it sends the Pwned Passwords service the first 5 characters of the password's fingerprint, never the password itself. +- Use a password for Vivier only. A reused password falls with the weakest website. +- Vivier only keeps a fingerprint of the password (Argon2id). Nobody, not even the account manager, can read it. + +To change it: **My account**, **Change password**. Your other sessions close. + +Forgot your password: the account manager gives you a temporary one, which you change at sign-in. + +# Blocking + +Vivier counts failed attempts. + +- 5 failures on a username, from the same address: blocked for 15 minutes. Then 30 minutes at the 10th failure, and 60 minutes at the 15th. +- 20 failures in an hour from the same address, any username: the address is blocked. +- A wrong two-step code counts as a failure. +- Wrong passwords typed to confirm a sensitive action count too. + +The block targets the address the attempts come from. A stranger therefore cannot block your account from their own place. + +You are blocked: wait for the time shown, or ask the account manager to lift the block. + +# Sessions + +- A session closes after 2 hours of inactivity. +- It always closes 12 hours after sign-in. +- Changing your password closes your other sessions. +- **My account**, **Sign out my other sessions** closes everything except the current session. Useful if you stayed signed in on another computer or lost a phone. +- A disabled account loses all its sessions at once. + +Vivier only works over HTTPS: an `http://` address is redirected, and the browser remembers for a year that it must use HTTPS. + +# Who sees what + +Every account reads and edits all profiles. Some actions affect the whole pool at once: they are for the account manager only. + +| Action | Every account | Account manager | +|---|---|---| +| Board, profiles, requests, follow-up, dashboard | Yes | Yes | +| Import, edit, comment on, archive a profile | Yes | Yes | +| IT Club and Europass CVs, export of one candidate's data | Yes | Yes | +| CSV export of the profile list | No | Yes | +| Activity log, retention, trash | No | Yes | +| Delete a profile | No | Yes | +| Accounts, invitations, sign-in log | No | Yes | +| Backup and file archive | No | Yes, with their password | + +The account manager types their password again to grant account management to another account, change someone else's password, reset a two-step verification or download the file archive. A session left open is therefore not enough for these actions. + +# The sign-in log + +Vivier records every sign-in attempt: date, account, IP address, browser, result. The log is kept for 12 months. + +- You see your latest sign-ins in **My account**. A sign-in you don't recognise: change your password and tell the account manager. +- The account manager sees all sign-ins, **Manage** menu, **Sign-ins**. +- An account manager signing in from a never-seen network triggers an alert email to IT Club. + +# The data + +- CVs, reports and photos are stored outside the public part of the site, under a random name. They only open after sign-in. +- Two-step verification keys are encrypted. The encryption key is kept outside the database. +- No Vivier page appears in a search engine. +- Every action on a profile is recorded with its author. +- With automatic analysis, the CV is sent to Anthropic, the maker of Claude, for analysis and translation. No other external service receives candidate data. + +# Good habits + +- Never share your password or a code, not even with the account manager. They never need it. +- Be wary of an email asking you to sign in. Type Vivier's address yourself. +- Lock your screen when you leave your desk. +- On a shared computer, use **Sign out** when you leave. +- Download a CV only when you need it, and delete the copy afterwards. +- Send an anonymised CV when the name is not needed. + +# Reporting a problem + +Tell the Vivier account manager right away if: + +- you see a sign-in you don't recognise +- you lost your phone or a computer where Vivier was open +- you typed your password on a suspicious page +- you receive a code or an invitation link you did not ask for + +The account manager can close your sessions, change your password, reset your two-step verification and check the sign-in log. diff --git a/aide/securite.fr.md b/aide/securite.fr.md new file mode 100644 index 0000000..b6ded07 --- /dev/null +++ b/aide/securite.fr.md @@ -0,0 +1,161 @@ +# Pourquoi Vivier protège ses accès + +Vivier contient les CV, les coordonnées et les échanges avec des centaines de candidats. Ces données sont personnelles. IT Club en est responsable devant les candidats et devant la loi (RGPD). + +Un mot de passe seul ne suffit plus : il se devine, se vole par un faux email ou circule après la fuite d'un autre site. Vivier ajoute donc plusieurs protections. Chacune bloque une attaque précise. + +| Protection | Ce qu'elle empêche | +|---|---| +| Accès sur invitation | Qu'un inconnu se crée un compte | +| Double authentification | Qu'un mot de passe volé suffise à entrer | +| Contrôle des mots de passe | L'usage d'un mot de passe déjà publié après une fuite | +| Blocage des essais | Qu'un programme essaie des milliers de mots de passe | +| Sessions limitées | Qu'un ordinateur oublié reste ouvert des jours | +| Journal des connexions | Qu'une intrusion passe inaperçue | +| Droits du gestionnaire | Qu'un seul compte compromis permette d'exporter tout le vivier | + +Cette page vaut pour tous les comptes. Elle se lit aussi sans être connecté, depuis la page de connexion. + +# Obtenir un compte : l'invitation + +Vivier n'a pas d'inscription libre. + +1. Le gestionnaire de Vivier crée un lien d'invitation à votre nom. +2. Il vous l'envoie, par email ou par la messagerie interne. +3. Vous ouvrez le lien, choisissez votre identifiant et votre mot de passe. +4. Vous activez la double authentification. Votre compte est prêt. + +Le lien sert une seule fois et expire après 48 heures. Lien expiré : demandez-en un nouveau. + +Le gestionnaire peut aussi créer votre compte avec un mot de passe provisoire. Vous le changez alors à la première connexion. Personne d'autre que vous ne connaît ensuite votre mot de passe. + +# La double authentification + +À chaque connexion, Vivier demande deux preuves : + +1. votre mot de passe, ce que vous savez +2. un code à 6 chiffres affiché par votre téléphone, ce que vous avez. + +Un voleur qui connaît votre mot de passe reste dehors sans votre téléphone. + +## L'activer + +À la première connexion, Vivier vous guide en trois étapes. + +1. Installez une application d'authentification : Google Authenticator, Microsoft Authenticator, ou le gestionnaire de mots de passe que vous utilisez déjà (Bitwarden, 1Password, Mots de passe d'Apple). +2. Dans l'application, ajoutez un compte et scannez le QR code affiché par Vivier. Sans appareil photo, saisissez la clé écrite sous le QR code. +3. Saisissez le code à 6 chiffres que l'application affiche. + +Le code change toutes les 30 secondes. L'application fonctionne sans réseau. + +## Les codes de secours + +Vivier vous remet ensuite dix codes de secours, de la forme `abcd-efgh`. Ils remplacent le code du téléphone si vous le perdez ou l'oubliez. + +- Chaque code sert une seule fois. +- Ils ne s'affichent qu'une fois. Copiez-les dans votre gestionnaire de mots de passe, ou imprimez-les et rangez la feuille. +- Il en reste peu ? **Mon compte**, **Générer de nouveaux codes de secours**. Les anciens cessent de marcher. + +## Changer de téléphone + +**Mon compte**, **Configurer une autre application**. Scannez le nouveau QR code avec le nouveau téléphone. L'ancien cesse de marcher, vos autres sessions se ferment et vous recevez dix nouveaux codes de secours. + +## Téléphone perdu + +1. Connectez-vous avec un code de secours, dans le champ du code. +2. Configurez tout de suite une autre application depuis **Mon compte**. + +Plus de code de secours : le gestionnaire remet votre double authentification à zéro, après avoir vérifié qu'il s'agit bien de vous. Vous l'activez de nouveau à la connexion suivante. + +## Le code est refusé + +- Vérifiez que l'heure de votre téléphone est réglée automatiquement. Un écart de plus d'une minute fausse le code. +- Saisissez le code affiché maintenant, pas celui d'avant. +- Un code déjà utilisé ne sert pas deux fois : attendez le suivant. + +# Le mot de passe + +- 12 caractères au moins. Une phrase de plusieurs mots se retient mieux qu'un mélange de signes : `quatre chats sur le toit bleu`. +- Vivier refuse un mot de passe connu dans des fuites de données. Pour le vérifier, il envoie au service Pwned Passwords les 5 premiers caractères de l'empreinte du mot de passe, jamais le mot de passe lui-même. +- Gardez un mot de passe propre à Vivier. Un mot de passe réutilisé tombe avec le site le moins protégé. +- Vivier ne garde qu'une empreinte du mot de passe (Argon2id). Personne, même le gestionnaire, ne peut le lire. + +Pour le changer : **Mon compte**, **Changer de mot de passe**. Vos autres sessions se ferment. + +Mot de passe oublié : le gestionnaire vous en donne un provisoire, que vous changez à la connexion. + +# Les blocages + +Vivier compte les essais manqués. + +- 5 échecs sur un identifiant, depuis une même adresse : blocage de 15 minutes. Puis 30 minutes au 10e échec, et 60 minutes au 15e. +- 20 échecs en une heure depuis une même adresse, tous identifiants confondus : l'adresse est bloquée. +- Un code de double authentification faux compte comme un échec. +- Les essais de mot de passe faits pour confirmer une action sensible comptent aussi. + +Le blocage vise l'adresse d'où viennent les essais. Un inconnu ne peut donc pas bloquer votre compte depuis chez lui. + +Vous êtes bloqué : attendez la durée affichée, ou demandez au gestionnaire de lever le blocage. + +# Les sessions + +- Une session se ferme après 2 heures sans activité. +- Elle se ferme dans tous les cas 12 heures après la connexion. +- Changer de mot de passe ferme vos autres sessions. +- **Mon compte**, **Déconnecter mes autres sessions** ferme tout sauf la session en cours. Utile après un oubli sur un autre ordinateur ou la perte d'un téléphone. +- Un compte désactivé perd aussitôt toutes ses sessions. + +Vivier ne fonctionne qu'en HTTPS : une adresse en `http://` est redirigée, et le navigateur retient pendant un an qu'il doit passer par HTTPS. + +# Qui voit quoi + +Tous les comptes lisent et modifient tous les profils. Certaines actions touchent tout le vivier d'un coup : elles sont réservées au gestionnaire. + +| Action | Tous les comptes | Gestionnaire | +|---|---|---| +| Kanban, profils, besoins, suivi, tableau de bord | Oui | Oui | +| Importer, modifier, commenter, archiver un profil | Oui | Oui | +| CV IT Club et Europass, export des données d'un candidat | Oui | Oui | +| Export CSV de la liste des profils | Non | Oui | +| Journal des actions, conservation, corbeille | Non | Oui | +| Supprimer un profil | Non | Oui | +| Comptes, invitations, journal des connexions | Non | Oui | +| Sauvegarde et archive des fichiers | Non | Oui, avec son mot de passe | + +Le gestionnaire retape son mot de passe pour confier la gestion à un autre compte, changer le mot de passe d'un autre, remettre à zéro une double authentification ou télécharger l'archive des fichiers. Une session laissée ouverte ne suffit donc pas pour ces actions. + +# Le journal des connexions + +Vivier note chaque essai de connexion : date, compte, adresse IP, navigateur, résultat. Le journal se garde 12 mois. + +- Vous voyez vos dernières connexions dans **Mon compte**. Une connexion que vous ne reconnaissez pas : changez votre mot de passe et prévenez le gestionnaire. +- Le gestionnaire voit toutes les connexions, menu **Gestion**, **Connexions**. +- Un compte gestionnaire qui se connecte depuis un réseau jamais vu déclenche un email d'alerte à IT Club. + +# Les données + +- Les CV, rapports et photos sont rangés hors de la partie publique du site, sous un nom aléatoire. Ils ne s'ouvrent qu'après connexion. +- Les clés de double authentification sont chiffrées. La clé de chiffrement est gardée hors de la base de données. +- Aucune page de Vivier n'apparaît dans un moteur de recherche. +- Chaque action sur un profil est tracée avec son auteur. +- Avec l'analyse automatique, le CV est transmis à Anthropic, éditeur de Claude, pour l'analyse et la traduction. Aucun autre service externe ne reçoit de données des candidats. + +# Les bons réflexes + +- Ne communiquez jamais votre mot de passe ni un code, même au gestionnaire. Il n'en a jamais besoin. +- Méfiez-vous d'un email qui vous demande de vous connecter. Tapez vous-même l'adresse de Vivier. +- Verrouillez votre écran quand vous quittez votre poste. +- Sur un ordinateur partagé, utilisez **Déconnexion** en partant. +- Téléchargez un CV seulement si vous en avez besoin, et supprimez la copie après usage. +- Envoyez un CV anonymisé quand le nom n'est pas nécessaire. + +# Signaler un problème + +Prévenez le gestionnaire de Vivier sans attendre si : + +- vous voyez une connexion que vous ne reconnaissez pas +- vous avez perdu votre téléphone ou un ordinateur où Vivier était ouvert +- vous avez saisi votre mot de passe sur une page douteuse +- vous recevez un code ou un lien d'invitation que vous n'avez pas demandé. + +Le gestionnaire peut fermer vos sessions, changer votre mot de passe, remettre à zéro votre double authentification et consulter le journal des connexions. diff --git a/claude/.htaccess b/claude/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/claude/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/claude/consignes-projet-claude.md b/claude/consignes-projet-claude.md new file mode 100644 index 0000000..4421028 --- /dev/null +++ b/claude/consignes-projet-claude.md @@ -0,0 +1,139 @@ +# Consignes du projet Claude « Analyse CV IT Club » + +À coller dans les instructions du projet. Ajoute aussi `cv-profil.schema.json` et `exemple-profil.json` dans les fichiers du projet. + +--- + +## Ton rôle + +Tu analyses des CV pour IT Club Luxembourg, société de conseil IT. Pour chaque CV reçu (PDF, DOCX, MD ou texte), tu produis deux fichiers : + +1. `profil--.json` : les données structurées, conformes à `cv-profil.schema.json` version 1.2. +2. `rapport--.md` : le rapport détaillé pour les recruteurs. + +Un CV par conversation. Si plusieurs CV arrivent ensemble, traite-les un par un et produis deux fichiers par candidat. + +## Règles d'extraction + +- Recopie ce que le CV dit. N'invente rien, ne déduis rien sur la personne. +- Une information absente vaut `null` (ou un tableau vide). Signale-la dans `points_attention`. +- Dates au format `AAAA-MM`. Si le mois manque, écris `AAAA`. Un poste en cours a `date_fin` à `null`. +- Normalise les noms de technologies : « Java », « Spring Boot », « PostgreSQL », « Angular ». Pas de variantes (« springboot », « Postgres »). +- Classe chaque expérience : `emploi`, `mission`, `alternance`, `stage` ou `independant`. +- Ignore l'âge, la date de naissance, la situation familiale et le genre. Ne les écris ni dans le JSON ni dans le rapport. +- Nationalité : seulement si le CV l'écrit, dans `identite.nationalites`, en codes pays ISO à deux lettres majuscules (`FR`, `LU`, `MA`, `IN`). Plusieurs nationalités possibles. Ne la déduis jamais du nom, de la langue, du lieu de naissance ou du pays de résidence. Absente du CV : tableau vide. Ne la mentionne pas dans le rapport. +- Titre de séjour ou permis de travail : n'écris rien dans le JSON. Si le CV en parle, recopie la mention telle quelle dans `points_attention` (« Le CV indique : permis de travail valable jusqu'en 2027 »). Le recruteur le saisit lui-même dans Vivier. +- Ne décris jamais la photo et n'en tire aucune conclusion sur la personne. Vivier la reprend lui-même depuis le fichier du CV. +- Indique dans `source.langue_cv` la langue du CV, sur deux lettres : `fr`, `en`, `de`. Vivier s'en sert pour savoir si une traduction est nécessaire. +- Respecte la langue du CV pour les intitulés de poste et de diplôme. Rédige le résumé et le rapport en français. +- N'écris jamais le nom ni le prénom du candidat dans `resume` ni dans les `description` des expériences. Rédige ces textes sans sujet nommé (« Conçoit et développe… », « 17 ans d'expérience en… »). Vivier réutilise ces textes dans le CV anonymisé. + +## Disponibilité, missions, formations, langues + +- `disponibilite` : la phrase du CV telle quelle (« Préavis de 2 mois »), ou `null`. +- `disponibilite_code` : la même information classée. `immediate` (disponible tout de suite), `1_mois`, `2_mois` ou `3_mois` (préavis, arrondi au mois supérieur jusqu'à 3), `date` (une date précise est écrite, à mettre dans `disponibilite_date` au format `AAAA-MM-JJ`, le 1er du mois si le jour manque), `a_preciser` (le CV en parle sans permettre de trancher, ou préavis de plus de 3 mois). `null` si le CV n'en dit rien. +- Expériences : `projet` (nom ou objet du projet), `mission` (rôle confié, une ou deux phrases), `charge` (« 100 % », « 3 jours par semaine ») seulement quand le CV les donne. Sinon `null`. Le détail des réalisations reste dans `description`. +- Formations : `date_debut` et `date_fin` au format `AAAA-MM` ou `AAAA`, et `lieu` (ville, pays) quand le CV les donne. Garde `annee_fin`. +- Langues : `niveau` porte le niveau global. Remplis `cecrl` seulement si le CV détaille les cinq compétences de la grille européenne (écoute, lecture, production orale, interaction orale, écriture), souvent sur un CV Europass. Sinon `null`. +- `benevolat` : une activité bénévole par élément, telle qu'écrite. `permis_conduire` : les catégories (`B`, `A`). Tableaux vides si le CV n'en parle pas. + +## Calcul de la séniorité + +1. Garde les expériences de type `emploi`, `mission`, `independant` et `alternance`. Exclus les `stage`. +2. Fusionne les périodes qui se chevauchent, pour ne pas compter deux fois le même mois. +3. Additionne les mois, divise par 12, arrondis à une décimale. C'est `annees_experience`. +4. Déduis le niveau : + - `junior` : moins de 4 ans + - `intermediaire` : de 4 ans à moins de 8 ans + - `confirme` : de 8 ans à moins de 12 ans + - `expert` : 12 ans et plus + +Pour un poste en cours, compte jusqu'à la date du jour. + +Pour chaque technologie, calcule `annees` de la même façon, sur les seules expériences qui la citent. + +## Mots-clés et métadonnées + +Ces champs alimentent la recherche de Vivier. Un recruteur tape « développeur Java senior » ou « XML » : le profil doit sortir si le CV le justifie. + +`mots_cles` : 15 à 30 termes, en minuscules. + +- Les métiers, les technologies principales, les méthodes, les domaines fonctionnels, les types de projets. +- Les équivalents dans l'autre langue : « développeur » et « developer », « chef de projet » et « project manager ». +- Les variantes d'écriture courantes : « back-end » et « backend », « ci/cd ». +- Uniquement ce que le CV démontre. Pas de terme ajouté pour faire remonter le profil. +- Pas de nom de personne, d'employeur ni de client. + +`metadonnees` : + +- `roles` : les métiers exercés, au masculin singulier, quelle que soit la personne. Écris « développeur », jamais « développeuse » ni « développeurs ». Exemples : développeur, architecte, technical lead, chef de projet, scrum master, product owner, analyste fonctionnel, testeur, devops, administrateur système, data engineer. +- `specialites` : back-end, front-end, full-stack, mobile, data, devops, cloud, sécurité, test, intégration, architecture, gestion de projet. +- `secteurs` : les secteurs des employeurs et des clients. Exemples : banque, assurance, institutions européennes, secteur public, télécoms, industrie, santé, commerce. +- `niveau_etudes` : le diplôme le plus élevé, parmi `bac`, `bac+2`, `bac+3`, `bac+4`, `bac+5`, `doctorat`, `autre`. `null` si le CV ne le dit pas. +- `encadrement` : `true` si le CV décrit l'encadrement d'une équipe, `false` sinon, `null` si le CV ne permet pas de trancher. + +Réutilise les mêmes termes d'un CV à l'autre. La recherche compare des mots : « institutions européennes » sur un profil et « institution UE » sur un autre ne se retrouvent pas. + +## Points d'attention + +Liste dans `points_attention` : + +- les périodes sans activité de plus de 6 mois +- les dates incohérentes ou qui se contredisent +- les informations manquantes (email, téléphone, niveau de langue) +- les technologies citées dans une liste de compétences mais absentes de toute expérience + +Reste factuel. Pas de jugement sur la personne. + +## Structure du rapport + +``` +# — + +## Synthèse +3 à 5 phrases : métier, années d'expérience, niveau de séniorité, domaines. + +## Parcours +Un paragraphe par expérience, de la plus récente à la plus ancienne : +dates, poste, employeur ou client, réalisations, technologies. + +## Compétences techniques +Tableau : technologie, catégorie, années de pratique, dernière utilisation. + +## Langues + +## Formation et certifications + +## Points forts +Uniquement ce que le CV démontre, avec l'expérience qui le prouve. + +## Points d'attention +Reprise de la liste du JSON, avec le détail. + +## Questions à poser en entretien +5 à 8 questions ciblées sur ce CV : vérifier un point flou, creuser une réalisation. +``` + +## Contrôle avant de répondre + +- Le JSON respecte le schéma : champs obligatoires présents, aucune clé en plus, valeurs d'énumération exactes. +- `schema_version` vaut `"1.2"`. +- `source.fichier_origine` contient le nom du fichier reçu, `source.date_analyse` la date du jour. +- Les années d'expérience du rapport et du JSON sont identiques. + +Si le fichier reçu n'est pas un CV, ou s'il est illisible, dis-le et ne produis aucun fichier. + +## Traduire un profil pour Vivier + +Vivier peut générer un CV dans une autre langue. Il fournit alors un fichier `traduction-itc-….json`, reconnaissable à son champ `"type": "traduction-vivier"`. + +Quand tu reçois ce fichier : + +1. Traduis chaque texte de `contenu` vers `langue_cible` (`fr`, `en` ou `de`). Les textes peuvent mêler deux langues : les intitulés suivent la langue du CV (`langue_source`), la synthèse et les points d'attention sont en français. Un texte déjà dans la langue d'arrivée reste tel quel. + Dans `metiers`, les clés sont des métiers : garde chaque clé et écris en valeur le métier traduit, au masculin singulier, en minuscules. +2. Renvoie un fichier JSON du même nom, avec exactement la même structure et les mêmes clés. Ne change ni `type`, ni `profil`, ni les langues, ni les numéros qui servent de clés. +3. Garde tels quels les noms de technologies, de produits, d'entreprises, de clients, de certifications et les sigles. +4. Traduis un intitulé de poste par l'intitulé d'usage dans la langue d'arrivée. +5. Garde les retours à la ligne. N'ajoute rien, ne résume pas, ne corrige pas le fond. + +Ne produis ni rapport ni commentaire : seul le fichier traduit est attendu. diff --git a/claude/cv-profil.schema.json b/claude/cv-profil.schema.json new file mode 100644 index 0000000..6696ddd --- /dev/null +++ b/claude/cv-profil.schema.json @@ -0,0 +1,606 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pi-consulting.lu/schemas/cv-profil-1.2.json", + "title": "Profil candidat IT Club", + "description": "Format d'échange entre Claude (analyse du CV) et Vivier. Version 1.2 : ajoute les nationalités, la disponibilité classée, le cadre des missions (projet, mission, charge), les dates et le lieu des formations, la grille européenne des langues, le bénévolat et le permis de conduire. Vivier accepte encore les versions 1.0 et 1.1.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "identite", + "experiences", + "technologies", + "langues", + "seniorite", + "source" + ], + "properties": { + "schema_version": { + "enum": [ + "1.0", + "1.1", + "1.2" + ] + }, + "identite": { + "type": "object", + "additionalProperties": false, + "required": [ + "prenom", + "nom" + ], + "properties": { + "prenom": { + "type": "string", + "minLength": 1 + }, + "nom": { + "type": "string", + "minLength": 1 + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "telephone": { + "type": [ + "string", + "null" + ] + }, + "adresse": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "rue": { + "type": [ + "string", + "null" + ] + }, + "code_postal": { + "type": [ + "string", + "null" + ] + }, + "ville": { + "type": [ + "string", + "null" + ] + }, + "pays": { + "type": [ + "string", + "null" + ] + } + } + }, + "nationalites": { + "type": "array", + "maxItems": 4, + "description": "Nationalités écrites dans le CV, en codes pays ISO 3166-1 alpha-2 majuscules (FR, LU, MA). Seulement si le CV les indique : ne jamais les déduire du nom, de la langue ou du pays de résidence.", + "items": { + "type": "string", + "pattern": "^[A-Z]{2}$" + } + } + } + }, + "titre": { + "type": [ + "string", + "null" + ], + "description": "Intitulé du profil, ex. Développeur Java" + }, + "resume": { + "type": [ + "string", + "null" + ], + "description": "Synthèse du profil en 3 à 5 phrases" + }, + "liens": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "enum": [ + "linkedin", + "github", + "portfolio", + "site", + "autre" + ] + }, + "url": { + "type": "string", + "format": "uri" + } + } + } + }, + "experiences": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "poste", + "date_debut", + "type" + ], + "properties": { + "poste": { + "type": "string", + "minLength": 1 + }, + "employeur": { + "type": [ + "string", + "null" + ] + }, + "client": { + "type": [ + "string", + "null" + ], + "description": "Client final si mission de consultance" + }, + "lieu": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "emploi", + "mission", + "alternance", + "stage", + "independant" + ] + }, + "date_debut": { + "type": "string", + "pattern": "^[0-9]{4}(-(0[1-9]|1[0-2]))?$", + "description": "AAAA-MM, ou AAAA si le mois manque" + }, + "date_fin": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}(-(0[1-9]|1[0-2]))?$", + "description": "null si poste en cours" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "technologies": { + "type": "array", + "items": { + "type": "string" + } + }, + "projet": { + "type": [ + "string", + "null" + ], + "description": "Nom ou objet du projet, s'il est cité" + }, + "mission": { + "type": [ + "string", + "null" + ], + "description": "Rôle confié, en une ou deux phrases" + }, + "charge": { + "type": [ + "string", + "null" + ], + "description": "Charge de travail si le CV la donne : 100 %, 3 jours par semaine, 120 jours-homme" + } + } + } + }, + "formations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "diplome" + ], + "properties": { + "diplome": { + "type": "string", + "minLength": 1 + }, + "etablissement": { + "type": [ + "string", + "null" + ] + }, + "annee_fin": { + "type": [ + "integer", + "null" + ], + "minimum": 1950, + "maximum": 2100 + }, + "date_debut": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}(-(0[1-9]|1[0-2]))?$", + "description": "AAAA-MM, ou AAAA" + }, + "date_fin": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}(-(0[1-9]|1[0-2]))?$", + "description": "AAAA-MM, ou AAAA. Garder aussi annee_fin" + }, + "lieu": { + "type": [ + "string", + "null" + ], + "description": "Ville et pays de l'établissement" + } + } + } + }, + "certifications": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "nom" + ], + "properties": { + "nom": { + "type": "string", + "minLength": 1 + }, + "organisme": { + "type": [ + "string", + "null" + ] + }, + "annee": { + "type": [ + "integer", + "null" + ], + "minimum": 1950, + "maximum": 2100 + } + } + } + }, + "technologies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "nom", + "categorie" + ], + "properties": { + "nom": { + "type": "string", + "minLength": 1, + "description": "Nom normalisé, ex. Java, Spring Boot, PostgreSQL" + }, + "categorie": { + "enum": [ + "langage", + "framework", + "base_de_donnees", + "outil", + "cloud", + "methode", + "autre" + ] + }, + "annees": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "description": "Années de pratique déduites des expériences" + } + } + } + }, + "langues": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "langue" + ], + "properties": { + "langue": { + "type": "string", + "minLength": 1 + }, + "niveau": { + "enum": [ + "A1", + "A2", + "B1", + "B2", + "C1", + "C2", + "natif", + null + ] + }, + "cecrl": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "description": "Grille européenne, seulement si le CV donne le détail par compétence (souvent un CV Europass). Sinon null : le niveau global suffit.", + "properties": { + "ecoute": { + "enum": [ + "A1", + "A2", + "B1", + "B2", + "C1", + "C2", + null + ] + }, + "lecture": { + "enum": [ + "A1", + "A2", + "B1", + "B2", + "C1", + "C2", + null + ] + }, + "production_orale": { + "enum": [ + "A1", + "A2", + "B1", + "B2", + "C1", + "C2", + null + ] + }, + "interaction_orale": { + "enum": [ + "A1", + "A2", + "B1", + "B2", + "C1", + "C2", + null + ] + }, + "ecriture": { + "enum": [ + "A1", + "A2", + "B1", + "B2", + "C1", + "C2", + null + ] + } + } + } + } + } + }, + "seniorite": { + "type": "object", + "additionalProperties": false, + "required": [ + "annees_experience", + "niveau" + ], + "properties": { + "annees_experience": { + "type": "number", + "minimum": 0 + }, + "niveau": { + "enum": [ + "junior", + "intermediaire", + "confirme", + "expert" + ] + } + } + }, + "disponibilite": { + "type": [ + "string", + "null" + ], + "description": "Disponibilité telle qu'écrite dans le CV, ex. Préavis de 2 mois" + }, + "disponibilite_code": { + "enum": [ + "immediate", + "1_mois", + "2_mois", + "3_mois", + "date", + "a_preciser", + null + ], + "description": "Disponibilité classée : immediate, 1_mois, 2_mois, 3_mois (préavis), date (date précise connue), a_preciser. null si le CV n'en dit rien." + }, + "disponibilite_date": { + "type": [ + "string", + "null" + ], + "format": "date", + "description": "AAAA-MM-JJ, obligatoire quand disponibilite_code vaut date" + }, + "points_attention": { + "type": "array", + "description": "Trous dans le parcours, dates incohérentes, informations manquantes", + "items": { + "type": "string" + } + }, + "mots_cles": { + "type": "array", + "maxItems": 60, + "description": "15 à 30 termes de recherche en minuscules : métiers, technologies clés, méthodes, domaines, avec leurs équivalents anglais ou français", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "metadonnees": { + "type": "object", + "additionalProperties": false, + "description": "Classement du profil pour les filtres et la recherche", + "properties": { + "roles": { + "type": "array", + "maxItems": 10, + "description": "Métiers exercés, forme canonique au masculin singulier : développeur, architecte, chef de projet, scrum master, product owner, analyste, testeur, devops, administrateur système, data engineer…", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "specialites": { + "type": "array", + "maxItems": 10, + "description": "Spécialités : back-end, front-end, full-stack, mobile, data, devops, cloud, sécurité, test, intégration, architecture, gestion de projet…", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "secteurs": { + "type": "array", + "maxItems": 10, + "description": "Secteurs des employeurs et clients : banque, assurance, institutions européennes, secteur public, télécoms, industrie, santé, commerce…", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "niveau_etudes": { + "enum": [ + "bac", + "bac+2", + "bac+3", + "bac+4", + "bac+5", + "doctorat", + "autre", + null + ] + }, + "encadrement": { + "type": [ + "boolean", + "null" + ], + "description": "true si le CV décrit l'encadrement d'une équipe" + } + } + }, + "benevolat": { + "type": "array", + "description": "Activités bénévoles citées dans le CV, une par élément", + "items": { + "type": "string", + "minLength": 1 + } + }, + "permis_conduire": { + "type": "array", + "description": "Catégories de permis de conduire citées dans le CV : B, A, C…", + "items": { + "type": "string", + "minLength": 1 + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "fichier_origine", + "date_analyse" + ], + "properties": { + "fichier_origine": { + "type": "string", + "minLength": 1 + }, + "date_analyse": { + "type": "string", + "format": "date" + }, + "langue_cv": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/claude/exemple-profil.json b/claude/exemple-profil.json new file mode 100644 index 0000000..24ec72b --- /dev/null +++ b/claude/exemple-profil.json @@ -0,0 +1,175 @@ +{ + "schema_version": "1.2", + "identite": { + "prenom": "Camille", + "nom": "Exemple", + "email": "camille.exemple@example.com", + "telephone": "+352 000 000 000", + "adresse": { + "rue": null, + "code_postal": "57000", + "ville": "Metz", + "pays": "France" + }, + "nationalites": [ + "FR" + ] + }, + "titre": "Développeuse Java", + "resume": "Développeuse back-end avec 9 ans d'expérience, dont 6 en consultance au Luxembourg. Travaille surtout en Java et Spring Boot, avec des bases PostgreSQL.", + "liens": [ + { + "type": "linkedin", + "url": "https://www.linkedin.com/in/camille-exemple" + } + ], + "experiences": [ + { + "poste": "Développeuse Java senior", + "employeur": "Société fictive A", + "client": "Institution fictive", + "lieu": "Luxembourg", + "type": "mission", + "date_debut": "2020-03", + "date_fin": null, + "projet": "Refonte de la gestion documentaire", + "mission": "Développement des services back-end et revue de code.", + "charge": "100 %", + "description": "Développement et maintenance d'une application de gestion documentaire.", + "technologies": [ + "Java", + "Spring Boot", + "PostgreSQL", + "Docker", + "XML" + ] + }, + { + "poste": "Développeuse Java", + "employeur": "Société fictive B", + "client": null, + "lieu": "Metz", + "type": "emploi", + "date_debut": "2017-09", + "date_fin": "2020-02", + "projet": null, + "mission": null, + "charge": null, + "description": "Développement d'API REST pour un portail client.", + "technologies": [ + "Java", + "Spring", + "MySQL" + ] + } + ], + "formations": [ + { + "diplome": "Master Informatique", + "etablissement": "Université fictive", + "annee_fin": 2017, + "date_debut": "2015-09", + "date_fin": "2017-06", + "lieu": "Metz, France" + } + ], + "certifications": [ + { + "nom": "Professional Scrum Master I", + "organisme": "Scrum.org", + "annee": 2021 + } + ], + "technologies": [ + { + "nom": "Java", + "categorie": "langage", + "annees": 9.1 + }, + { + "nom": "Spring Boot", + "categorie": "framework", + "annees": 6.6 + }, + { + "nom": "PostgreSQL", + "categorie": "base_de_donnees", + "annees": 6.6 + }, + { + "nom": "XML", + "categorie": "langage", + "annees": 6.6 + }, + { + "nom": "Docker", + "categorie": "outil", + "annees": 6.6 + } + ], + "langues": [ + { + "langue": "Français", + "niveau": "natif", + "cecrl": null + }, + { + "langue": "Anglais", + "niveau": "C1", + "cecrl": null + } + ], + "seniorite": { + "annees_experience": 9.1, + "niveau": "confirme" + }, + "disponibilite": "Préavis de 2 mois", + "disponibilite_code": "2_mois", + "disponibilite_date": null, + "points_attention": [ + "Adresse postale incomplète sur le CV." + ], + "mots_cles": [ + "développeur", + "developer", + "java", + "spring boot", + "back-end", + "backend", + "api rest", + "postgresql", + "docker", + "xml", + "gestion documentaire", + "portail client", + "scrum", + "agile", + "consultance", + "luxembourg" + ], + "metadonnees": { + "roles": [ + "développeur" + ], + "specialites": [ + "back-end" + ], + "secteurs": [ + "institutions européennes", + "services" + ], + "niveau_etudes": "bac+5", + "encadrement": false + }, + "benevolat": [ + "Animatrice d'ateliers de code pour collégiens, association fictive (2019 à 2022)" + ], + "permis_conduire": [ + "B" + ], + "source": { + "fichier_origine": "CV_Camille_Exemple.pdf", + "date_analyse": "2026-10-08", + "langue_cv": "fr" + } +} diff --git a/config/.htaccess b/config/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/config/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/config/config.exemple.php b/config/config.exemple.php new file mode 100644 index 0000000..e7531d2 --- /dev/null +++ b/config/config.exemple.php @@ -0,0 +1,85 @@ + 'Vivier', + + // Fuseau horaire des dates affichées et enregistrées + 'fuseau' => 'Europe/Luxembourg', + + // Base MySQL : valeurs fournies par l'espace client OVH (onglet Bases de données) + 'db' => [ + 'hote' => 'itclublurcvivier.mysql.db', + 'port' => 3306, + 'base' => 'itclublurcvivier', + 'utilisateur' => 'itclublurcvivier', + 'mot_de_passe' => 'xxxxx', + ], + + // Mentions de la société sur les CV exportés + 'societe' => [ + 'nom' => 'IT Club S.à r.l.', + 'adresse' => '63 rue de Hollerich L-1741 Luxembourg', + 'tva' => 'LU30929924', + 'rcs' => 'B231336', + 'site' => 'itclub.lu', + 'email' => 'sales@itclub.lu', + 'telephone' => '+352 621 37 37 95', + 'signature' => 'Our people is our strength', + ], + // Analyse automatique des CV par l'API Claude. Laissez cle_api vide pour la désactiver : + // l'import par fichiers déjà analysés reste disponible. + // La clé se crée dans la console Anthropic. Son usage est facturé à part d'un abonnement Claude. + 'claude' => [ + 'cle_api' => '', + 'modele' => 'claude-sonnet-5-5', + 'max_tokens' => 12000, + 'delai' => 240, // secondes d'attente au plus + ], + + // Sauvegarde hebdomadaire de la base, envoyée par email par la tâche planifiée (cron/sauvegarde.php). + // Le fichier joint n'est pas chiffré : il contient les données des candidats. + 'sauvegarde' => [ + 'destinataires' => ['ypilla@gmail.com', 'yann.pilla@itclub.lu'], + 'expediteur' => 'vivier@itclub.lu', + 'semaines_conservees' => 0, // copies gardées sur le serveur : 0 = sans limite, sinon un nombre de semaines + 'taille_max_email' => 7 * 1024 * 1024, // au-delà, l'email part sans pièce jointe (limite OVH : 10 Mo par email) + ], + + // Alertes de sécurité : adresses prévenues quand un gestionnaire se connecte depuis un réseau jamais vu. + // Sans cette entrée, l'email part à l'adresse d'expédition ci-dessus. + 'comptes' => [ + 'notifier' => ['vivier@itclub.lu'], + ], + + // Adresse publique de Vivier, utilisée dans les liens d'invitation. Facultative : sans elle, + // Vivier reprend l'adresse de la page ouverte. + 'adresse' => 'https://vivier.itclub.lu', + + // Clé de chiffrement des clés de double authentification : 32 octets en base64. + // Laissez vide : Vivier crée alors config/cle-secrets.php au premier besoin. Gardez ce fichier + // avec config.php : sans lui, chacun doit utiliser un code de secours puis réactiver sa double authentification. + // Pour la fixer vous-même : php -r "echo base64_encode(random_bytes(32));" + 'cle_secrets' => '', + + 'securite' => [ + // Refuser les mots de passe connus dans des fuites de données (service Pwned Passwords). + // Seuls les 5 premiers caractères de l'empreinte du mot de passe quittent le serveur. + // Si le service ne répond pas, le mot de passe est accepté. + 'mots_de_passe_fuites' => true, + ], + + // Dossier des fichiers (CV, rapports, pièces jointes). Hors de la racine web. + 'stockage' => __DIR__ . '/../stockage', + + // Taille maximale d'un fichier chargé, en octets (10 Mo) + 'taille_max' => 10 * 1024 * 1024, + + // Durée de conservation d'un profil, en mois, à partir de l'import + 'conservation_mois' => 24, + + // true en production : HTTPS obligatoire (redirection, HSTS d'un an) et cookie de session limité à HTTPS. + // false seulement pour un essai sur un poste local. + 'https' => true, +]; diff --git a/cron/.htaccess b/cron/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/cron/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/cron/sauvegarde.php b/cron/sauvegarde.php new file mode 100644 index 0000000..afe66fb --- /dev/null +++ b/cron/sauvegarde.php @@ -0,0 +1,39 @@ +getMessage() . "\n"; +} + +// Le journal des connexions garde 12 mois ; les liens d'invitation, 12 mois après leur création +try { + purger_connexions(); + db()->exec('DELETE FROM invitations WHERE cree_le < DATE_SUB(NOW(), INTERVAL ' . CONNEXIONS_MOIS . ' MONTH)'); + echo date('Y-m-d H:i') . " Vivier, journal des connexions : purge faite\n"; +} catch (Throwable $ex) { + echo date('Y-m-d H:i') . ' Vivier, journal des connexions : ECHEC, ' . $ex->getMessage() . "\n"; +} + +// Toujours 0 : OVH désactive une tâche après 10 échecs, ce qui arrêterait aussi les sauvegardes suivantes. +// Un échec est signalé par l'email d'alerte et par le tableau de bord de Vivier. +exit(0); diff --git a/lang/.htaccess b/lang/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/lang/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/lang/de.php b/lang/de.php new file mode 100644 index 0000000..3d0ddaf --- /dev/null +++ b/lang/de.php @@ -0,0 +1,93 @@ + 'Erfahrungsstufe', + 'Junior' => 'Junior', + 'Intermédiaire' => 'Professional', + 'Confirmé' => 'Senior', + 'Expert' => 'Experte', + 'Expérience' => 'Berufserfahrung', + '%s ans' => '%s Jahre', + '%s an' => '%s Jahr', + 'Langues' => 'Sprachen', + 'natif' => 'Muttersprache', + 'Disponibilité' => 'Verfügbarkeit', + 'Localisation' => 'Standort', + 'Synthèse' => 'Kurzprofil', + 'Compétences techniques' => 'Technische Kenntnisse', + 'Expériences professionnelles' => 'Beruflicher Werdegang', + 'Formation' => 'Ausbildung', + 'Certifications' => 'Zertifizierungen', + 'Environnement :' => 'Umfeld:', + 'Client : %s' => 'Kunde: %s', + 'en cours' => 'heute', + 'Profil %s' => 'Profil %s', + 'Pour rencontrer ce consultant : %s' => 'Kontakt zu diesem Berater: %s', + 'TVA %s' => 'USt-IdNr. %s', + 'Langages' => 'Programmiersprachen', + 'Frameworks' => 'Frameworks', + 'Bases de données' => 'Datenbanken', + 'Outils' => 'Tools', + 'Cloud' => 'Cloud', + 'Méthodes' => 'Methoden', + 'Autres' => 'Sonstiges', + 'Français' => 'Französisch', + 'Anglais' => 'Englisch', + 'Allemand' => 'Deutsch', + 'Luxembourgeois' => 'Luxemburgisch', + 'Néerlandais' => 'Niederländisch', + 'Espagnol' => 'Spanisch', + 'Italien' => 'Italienisch', + 'Portugais' => 'Portugiesisch', + 'Arabe' => 'Arabisch', + 'Russe' => 'Russisch', + 'Polonais' => 'Polnisch', + 'Roumain' => 'Rumänisch', + 'Chinois' => 'Chinesisch', + 'Grec' => 'Griechisch', + 'Turc' => 'Türkisch', + + // Version 1.8 : disponibilité, cadre des missions, CV Europass + 'Immédiate' => 'Sofort', + 'Sous 1 mois' => 'Innerhalb von 1 Monat', + 'Sous 2 mois' => 'Innerhalb von 2 Monaten', + 'Sous 3 mois' => 'Innerhalb von 3 Monaten', + 'Date précise' => 'Zu einem bestimmten Datum', + 'À préciser' => 'Nach Absprache', + 'À partir du %s' => 'Ab %s', + 'vers le %s' => 'etwa %s', + 'Projet' => 'Projekt', + 'Projet : %s' => 'Projekt: %s', + 'Mission' => 'Aufgabe', + 'Charge' => 'Auslastung', + 'Charge : %s' => 'Auslastung: %s', + 'Client' => 'Kunde', + 'Technologies' => 'Technologien', + 'Adresse' => 'Adresse', + 'Adresse électronique' => 'E-Mail-Adresse', + 'Numéro de téléphone' => 'Telefonnummer', + 'À propos de moi' => 'Über mich', + 'Éducation et formation' => 'Allgemeine und berufliche Bildung', + 'Expérience professionnelle' => 'Berufserfahrung', + 'Compétences linguistiques' => 'Sprachkenntnisse', + 'Langue(s) maternelle(s) :' => 'Muttersprache(n):', + 'Autre(s) langue(s) :' => 'Andere Sprache(n):', + 'Compréhension' => 'Verstehen', + 'Expression orale' => 'Sprechen', + 'Écrit' => 'Schreiben', + 'Compréhension orale' => 'Hören', + 'Compréhension écrite' => 'Lesen', + 'Expression orale en continu' => 'Zusammenhängendes Sprechen', + 'Interaction orale' => 'An Gesprächen teilnehmen', + 'Niveaux : A1 et A2 : utilisateur de base ; B1 et B2 : utilisateur indépendant ; C1 et C2 : utilisateur expérimenté' => 'Niveaus: A1 und A2: elementare Sprachverwendung; B1 und B2: selbständige Sprachverwendung; C1 und C2: kompetente Sprachverwendung', + 'Niveau' => 'Niveau', + 'Compétences' => 'Fähigkeiten und Kompetenzen', + 'Technique' => 'Technik', + 'Logiciels et outils' => 'Software und Tools', + 'Bénévolat' => 'Ehrenamtliche Tätigkeit', + 'Permis de conduire' => 'Führerschein', + 'Permis de conduire :' => 'Führerschein:', + 'Rôle et réalisations principales :' => 'Hauptaufgaben und Ergebnisse:', +]; diff --git a/lang/en.php b/lang/en.php new file mode 100644 index 0000000..47085b6 --- /dev/null +++ b/lang/en.php @@ -0,0 +1,1218 @@ + 'Skip to content', + 'Menu' => 'Menu', + 'Navigation principale' => 'Main navigation', + 'Kanban' => 'Board', + 'Profils' => 'Profiles', + 'Importer' => 'Import', + 'Tableau de bord' => 'Dashboard', + 'Conservation' => 'Retention', + 'Journal' => 'Activity log', + 'Comptes' => 'Accounts', + 'Mon compte' => 'My account', + 'Déconnexion' => 'Sign out', + 'Rechercher' => 'Search', + 'Rechercher un profil' => 'Search for a profile', + 'développeur Java senior' => 'senior Java developer', + 'Le suivi des CV d\'IT Club' => 'IT Club\'s CV tracker', + 'Retour au Kanban' => 'Back to the board', + 'Page introuvable' => 'Page not found', + 'Profil introuvable' => 'Profile not found', + 'Rapport introuvable' => 'Report not found', + 'Document introuvable.' => 'Document not found.', + 'Candidat introuvable.' => 'Candidate not found.', + 'Une erreur est survenue' => 'Something went wrong', + 'L\'action n\'a pas abouti. Revenez en arrière et réessayez. Si l\'erreur persiste, prévenez le gestionnaire de Vivier.' => 'The action did not go through. Go back and try again. If the error persists, tell the Vivier account manager.', + 'Session expirée ou requête invalide. Rechargez la page.' => 'Session expired or invalid request. Reload the page.', + 'Requête invalide.' => 'Invalid request.', + 'Action non autorisée pour ce compte.' => 'This account is not allowed to do that.', + 'Page réservée au gestionnaire des comptes.' => 'This page is for the account manager only.', + 'Configuration absente : copiez config/config.exemple.php vers config/config.php.' => 'Configuration missing: copy config/config.exemple.php to config/config.php.', + 'Dossier de stockage inaccessible.' => 'The storage folder cannot be reached.', + + // --- Connexion et comptes + 'Connexion' => 'Sign in', + 'Installation' => 'Setup', + 'Identifiant' => 'Username', + 'Identifiant de connexion' => 'Username', + 'Mot de passe' => 'Password', + 'Se connecter' => 'Sign in', + 'Identifiant ou mot de passe incorrect.' => 'Wrong username or password.', + 'Trop d\'essais infructueux. Réessayez dans %d minutes.' => 'Too many failed attempts. Try again in %d minutes.', + 'Premier lancement : créez votre compte. Vous gérerez ensuite ceux des autres.' => 'First run: create your account. You will then manage everyone else\'s.', + 'Votre nom' => 'Your name', + 'Mot de passe, %d caractères au moins' => 'Password, at least %d characters', + 'Nouveau mot de passe, %d caractères au moins' => 'New password, at least %d characters', + 'Nouveau mot de passe, à laisser vide pour le garder' => 'New password, leave empty to keep the current one', + 'Confirmation' => 'Confirm password', + 'Créer mon compte' => 'Create my account', + 'Les deux mots de passe diffèrent.' => 'The two passwords do not match.', + 'Les deux nouveaux mots de passe diffèrent.' => 'The two new passwords do not match.', + 'Le mot de passe doit compter au moins %d caractères.' => 'The password must be at least %d characters long.', + 'Le mot de passe actuel est incorrect.' => 'The current password is wrong.', + 'Mot de passe actuel' => 'Current password', + 'Changer de mot de passe' => 'Change password', + 'Changer le mot de passe' => 'Change password', + 'Mot de passe modifié.' => 'Password changed.', + 'Nom' => 'Name', + 'Chaque compte lit et modifie tous les profils. Vous choisissez l\'identifiant et le mot de passe de chacun.' => 'Every account can read and edit all profiles. You choose each person\'s username and password.', + 'Créer un compte' => 'Create an account', + 'Créer le compte' => 'Create account', + 'Nom de la personne' => 'Person\'s name', + 'prenom.nom' => 'first.last', + 'vous' => 'you', + 'Gère les comptes' => 'Manages accounts', + 'Désactivé' => 'Disabled', + 'Dernière connexion' => 'Last sign-in', + 'jamais' => 'never', + 'Modifier l\'identifiant ou le mot de passe' => 'Change username or password', + 'Enregistrer' => 'Save', + 'Désactiver le compte' => 'Disable account', + 'Réactiver le compte' => 'Enable account', + 'Retirer la gestion des comptes' => 'Remove account management', + 'Confier la gestion des comptes' => 'Grant account management', + 'Compte créé. Transmettez l\'identifiant et le mot de passe à la personne.' => 'Account created. Give the username and password to the person.', + 'Compte modifié.' => 'Account updated.', + 'Compte mis à jour.' => 'Account updated.', + 'Cet identifiant est déjà pris.' => 'This username is already taken.', + 'L\'identifiant compte 3 à 40 caractères : lettres sans accent, chiffres, point, tiret.' => 'A username has 3 to 40 characters: unaccented letters, digits, dot, hyphen.', + 'Indiquez le nom de la personne.' => 'Enter the person\'s name.', + 'Rôle inconnu.' => 'Unknown role.', + 'Vous ne pouvez pas désactiver votre propre compte.' => 'You cannot disable your own account.', + 'Vous ne pouvez pas retirer votre propre gestion des comptes.' => 'You cannot remove account management from yourself.', + 'Gestionnaire des comptes' => 'Account manager', + 'Utilisateur' => 'User', + 'Index de recherche' => 'Search index', + 'À relancer après une mise à jour de Vivier.' => 'Run again after a Vivier update.', + 'Réindexer tous les profils' => 'Reindex all profiles', + '%d profil réindexé.' => '%d profile reindexed.', + '%d profils réindexés.' => '%d profiles reindexed.', + + // --- Statuts, séniorité, tables de libellés + 'Reçu' => 'Received', + 'En cours' => 'In progress', + 'Premier entretien validé' => 'First interview passed', + 'Second entretien validé' => 'Second interview passed', + 'Non retenu' => 'Rejected', + 'Validé' => 'Approved', + 'Embauché' => 'Hired', + 'Junior' => 'Junior', + 'Intermédiaire' => 'Mid-level', + 'Confirmé' => 'Senior', + 'Expert' => 'Expert', + 'Métiers' => 'Roles', + 'Spécialités' => 'Specialities', + 'Secteurs' => 'Sectors', + 'Mots-clés' => 'Keywords', + 'Bac' => 'Secondary school diploma', + 'Bac+2' => '2-year degree', + 'Bac+3' => 'Bachelor\'s degree', + 'Bac+4' => '4-year degree', + 'Bac+5' => 'Master\'s degree', + 'Doctorat' => 'Doctorate', + 'Autre' => 'Other', + 'CV d\'origine' => 'Original CV', + 'Rapport' => 'Report', + 'CV IT Club' => 'IT Club CV', + 'CV anonymisé' => 'Anonymised CV', + 'emploi' => 'employment', + 'mission' => 'assignment', + 'alternance' => 'work-study', + 'stage' => 'internship', + 'indépendant' => 'freelance', + 'Langage' => 'Language', + 'Framework' => 'Framework', + 'Base de données' => 'Database', + 'Outil' => 'Tool', + 'Cloud' => 'Cloud', + 'Méthode' => 'Method', + 'Oui' => 'Yes', + 'Non' => 'No', + 'ou' => 'or', + 'natif' => 'native', + + // --- Kanban + '%d profil' => '%d profile', + '%d profils' => '%d profiles', + 'Filtrer les cartes' => 'Filter cards', + 'Nom, titre ou technologie' => 'Name, title or technology', + 'Séniorité' => 'Seniority', + 'Toutes' => 'All', + 'Tous' => 'All', + 'Importer un profil' => 'Import a profile', + 'Motif du refus' => 'Reason for rejection', + 'Pourquoi ce profil n\'est pas retenu ?' => 'Why is this profile rejected?', + 'Annuler' => 'Cancel', + 'Indiquez le motif du refus.' => 'Enter the reason for rejection.', + 'Statut inconnu.' => 'Unknown status.', + 'Statut mis à jour.' => 'Status updated.', + 'Le changement n\'a pas été enregistré.' => 'The change was not saved.', + 'Connexion perdue. Le changement n\'a pas été enregistré.' => 'Connection lost. The change was not saved.', + '%s ans' => '%s years', + '%s an' => '%s year', + + // --- Import + 'Import refusé : %d points à corriger.' => 'Import rejected: %d issues to fix.', + 'Import refusé.' => 'Import rejected.', + 'Analyse automatique' => 'Automatic analysis', + 'Chargez le CV. Vivier l\'envoie à Claude, qui extrait le profil et rédige le rapport. Vous relisez avant d\'enregistrer.' => 'Upload the CV. Vivier sends it to Claude, which extracts the profile and writes the report. You review before saving.', + 'Analyse en cours. Comptez une à deux minutes, sans fermer la page.' => 'Analysis in progress. Allow one to two minutes and keep this page open.', + 'Analyse en cours…' => 'Analysing…', + 'CV du candidat (PDF, DOCX, ODT, MD ou TXT)' => 'Candidate\'s CV (PDF, DOCX, ODT, MD or TXT)', + 'Analyser le CV' => 'Analyse CV', + 'Non configurée. Ajoutez une clé d\'API Claude dans' => 'Not set up. Add a Claude API key in', + 'pour analyser un CV sans quitter Vivier.' => 'to analyse a CV without leaving Vivier.', + 'Fichiers déjà analysés' => 'Files already analysed', + 'Chargez les fichiers produits par votre projet Claude « Analyse CV IT Club », avec le CV d\'origine.' => 'Upload the files produced by your "Analyse CV IT Club" Claude project, along with the original CV.', + 'Profil structuré (.json)' => 'Structured profile (.json)', + 'CV d\'origine (PDF, DOCX, DOC, ODT, RTF, MD ou TXT)' => 'Original CV (PDF, DOCX, DOC, ODT, RTF, MD or TXT)', + 'Rapport détaillé, facultatif (MD, TXT, PDF ou DOCX)' => 'Detailed report, optional (MD, TXT, PDF or DOCX)', + 'Contrôler le profil' => 'Check profile', + 'Relecture avant enregistrement' => 'Review before saving', + 'Profil extrait par l\'analyse automatique. Vérifiez l\'identité et les dates avant d\'enregistrer.' => 'Profile extracted by automatic analysis. Check the identity and the dates before saving.', + 'Photo trouvée dans le CV' => 'Photo found in the CV', + 'Garder cette photo' => 'Keep this photo', + 'Candidat' => 'Candidate', + 'Titre' => 'Title', + 'Email' => 'Email', + 'Téléphone' => 'Phone', + 'Adresse' => 'Address', + 'non renseigné' => 'not provided', + 'non renseignée' => 'not provided', + '%s, recalculés à partir des dates' => '%s, recomputed from the dates', + 'Contenu' => 'Content', + '%d expérience' => '%d position', + '%d expériences' => '%d positions', + '%d technologie' => '%d technology', + '%d technologies' => '%d technologies', + '%d langue' => '%d language', + '%d langues' => '%d languages', + '%d formation' => '%d degree', + '%d formations' => '%d degrees', + '%d certification' => '%d certification', + '%d certifications' => '%d certifications', + 'Fichiers' => 'Files', + 'Écart avec l\'analyse : le fichier annonce %s (%s). Vivier retient la valeur recalculée. Vérifiez les dates des expériences.' => 'Mismatch with the analysis: the file states %s (%s). Vivier keeps the recomputed value. Check the dates of the positions.', + 'Points d\'attention' => 'Points to check', + 'Expériences' => 'Experience', + 'Période' => 'Period', + 'Poste' => 'Position', + 'Employeur ou client' => 'Employer or client', + 'Type' => 'Type', + 'en cours' => 'present', + 'Enregistrer le profil' => 'Save profile', + 'Import annulé.' => 'Import cancelled.', + 'Profil enregistré dans la colonne Reçu.' => 'Profile saved in the Received column.', + 'Le chargement du fichier a échoué.' => 'The file upload failed.', + 'Fichier trop volumineux (maximum %d Mo).' => 'File too large (maximum %d MB).', + 'Format non accepté : .%s. Formats acceptés : %s.' => 'Format not accepted: .%s. Accepted formats: %s.', + 'Le fichier de profil n\'est pas un JSON valide : %s.' => 'The profile file is not valid JSON: %s.', + 'Le fichier de profil n\'est pas un JSON valide.' => 'The profile file is not valid JSON.', + 'Le fichier n\'a pas pu être enregistré.' => 'The file could not be saved.', + 'L\'analyse automatique n\'est pas configurée.' => 'Automatic analysis is not set up.', + 'L\'analyse automatique a produit un profil non conforme. Relancez-la, ou passez par les fichiers déjà analysés.' => 'Automatic analysis produced a non-compliant profile. Run it again, or use the files already analysed.', + 'L\'analyse automatique lit les fichiers PDF, DOCX, ODT, MD et TXT. Convertissez ce fichier en PDF.' => 'Automatic analysis reads PDF, DOCX, ODT, MD and TXT files. Convert this file to PDF.', + 'Le fichier contient trop peu de texte. S\'il s\'agit d\'un document scanné, chargez-le en PDF.' => 'The file contains too little text. If it is a scanned document, upload it as a PDF.', + 'Le fichier est illisible ou endommagé.' => 'The file is unreadable or damaged.', + 'L\'analyse a été coupée avant la fin : le CV est trop long. Passez par l\'import de fichiers déjà analysés.' => 'The analysis was cut off before the end: the CV is too long. Use the import of files already analysed.', + 'Analyse impossible : %s' => 'Analysis not possible: %s', + 'La réponse de l\'analyse ne contient pas de profil. Réessayez.' => 'The analysis response contains no profile. Try again.', + 'Le profil renvoyé par l\'analyse n\'est pas un JSON valide. Réessayez.' => 'The profile returned by the analysis is not valid JSON. Try again.', + 'L\'analyse a dépassé le délai de %d secondes. Réessayez, ou passez par l\'import de fichiers déjà analysés.' => 'The analysis exceeded the %d-second limit. Try again, or use the import of files already analysed.', + 'Le service d\'analyse est injoignable. Vérifiez la connexion du serveur.' => 'The analysis service cannot be reached. Check the server\'s connection.', + 'La clé d\'API Claude est refusée. Vérifiez-la dans config/config.php.' => 'The Claude API key was rejected. Check it in config/config.php.', + 'Trop de demandes ou crédit épuisé côté API Claude. Réessayez plus tard.' => 'Too many requests or no credit left on the Claude API side. Try again later.', + 'Le service d\'analyse est momentanément indisponible. Réessayez dans quelques minutes.' => 'The analysis service is temporarily unavailable. Try again in a few minutes.', + 'L\'analyse a échoué (code %d). %s' => 'The analysis failed (code %d). %s', + + // --- Contrôle du fichier de profil + 'Le fichier ne contient pas un objet JSON.' => 'The file does not contain a JSON object.', + 'version non gérée (attendu : %s).' => 'unsupported version (expected: %s).', + 'adresse invalide.' => 'invalid address.', + 'objet attendu.' => 'object expected.', + 'adresse http(s) attendue.' => 'http(s) address expected.', + 'format AAAA-MM ou AAAA attendu.' => 'YYYY-MM or YYYY format expected.', + 'format AAAA-MM, AAAA ou null attendu.' => 'YYYY-MM, YYYY or null expected.', + 'la date de fin précède la date de début.' => 'the end date is before the start date.', + 'texte attendu.' => 'text expected.', + 'nombre positif ou null attendu.' => 'positive number or null expected.', + 'nombre positif attendu.' => 'positive number expected.', + 'valeur inconnue (attendu : %s ou null).' => 'unknown value (expected: %s or null).', + 'valeur inconnue (attendu : %s).' => 'unknown value (expected: %s).', + 'true, false ou null attendu.' => 'true, false or null expected.', + 'date AAAA-MM-JJ attendue.' => 'YYYY-MM-DD date expected.', + 'champ obligatoire manquant « %s ».' => 'required field "%s" is missing.', + 'clé inconnue « %s ».' => 'unknown key "%s".', + 'texte non vide attendu.' => 'non-empty text expected.', + 'texte ou null attendu.' => 'text or null expected.', + 'année entre 1950 et 2100 ou null attendue.' => 'year between 1950 and 2100, or null, expected.', + '%d éléments au maximum.' => '%d items at most.', + 'texte de 1 à 80 caractères attendu.' => 'text of 1 to 80 characters expected.', + 'liste attendue.' => 'list expected.', + + // --- Fiche profil + '%s d\'expérience' => '%s of experience', + 'Télécharger le CV d\'origine' => 'Download original CV', + 'Modifier' => 'Edit', + 'Statut' => 'Status', + 'Mettre à jour' => 'Update', + 'Synthèse' => 'Summary', + 'client : %s' => 'client: %s', + 'Aucune expérience renseignée.' => 'No position recorded.', + 'Technologies' => 'Technologies', + 'Technologie' => 'Technology', + 'Catégorie' => 'Category', + 'Années' => 'Years', + 'Aucune technologie renseignée.' => 'No technology recorded.', + 'Formation et certifications' => 'Education and certifications', + 'Coordonnées' => 'Contact details', + 'Disponibilité' => 'Availability', + 'Langues' => 'Languages', + 'Aucune langue renseignée.' => 'No language recorded.', + 'Photo' => 'Photo', + 'Sert à reconnaître le candidat dans Vivier. Elle ne figure pas sur les CV exportés.' => 'Helps recognise the candidate in Vivier. It never appears on exported CVs.', + 'Remplacer la photo' => 'Replace photo', + 'Ajouter une photo' => 'Add a photo', + 'Enregistrer la photo' => 'Save photo', + 'Retirer la photo' => 'Remove photo', + 'Retirer la photo de ce profil ?' => 'Remove the photo from this profile?', + 'Photo enregistrée.' => 'Photo saved.', + 'Photo retirée.' => 'Photo removed.', + 'Cette image est illisible. Chargez un fichier JPEG ou PNG.' => 'This image cannot be read. Upload a JPEG or PNG file.', + 'La photo n\'a pas pu être enregistrée.' => 'The photo could not be saved.', + 'Mots-clés et classement' => 'Keywords and classification', + 'Études' => 'Education', + 'Encadrement' => 'Team management', + 'Ajouté à la main' => 'Added by hand', + 'Retirer %s' => 'Remove %s', + 'Aucun mot-clé. La recherche s\'appuie sur le reste de la fiche.' => 'No keywords. Search relies on the rest of the profile.', + 'Ajouter' => 'Add', + 'Documents' => 'Documents', + 'Lire' => 'Read', + 'Supprimer' => 'Delete', + 'Supprimer ce document ?' => 'Delete this document?', + 'Ajouter un document' => 'Add a document', + 'Document ajouté.' => 'Document added.', + 'Document supprimé.' => 'Document deleted.', + 'Commentaires' => 'Comments', + 'Nouveau commentaire' => 'New comment', + 'Publier' => 'Post', + 'Aucun commentaire.' => 'No comments.', + 'Le commentaire est vide.' => 'The comment is empty.', + 'Historique' => 'History', + 'Gestion' => 'Manage', + 'Mettre à jour depuis une nouvelle analyse' => 'Update from a new analysis', + 'Remplace les expériences, technologies, langues et mots-clés. Garde le statut, les commentaires, les documents et les mots ajoutés à la main.' => 'Replaces positions, technologies, languages and keywords. Keeps the status, comments, documents and keywords added by hand.', + 'Nouveau CV, facultatif' => 'New CV, optional', + 'Nouveau rapport, facultatif' => 'New report, optional', + 'Supprimer le profil' => 'Delete profile', + 'Supprimer définitivement ce profil, ses documents, ses commentaires et son historique ?' => 'Permanently delete this profile, its documents, comments and history?', + 'Supprimer définitivement ce profil et ses documents ?' => 'Permanently delete this profile and its documents?', + 'Profil %s supprimé, avec ses documents.' => 'Profile %s deleted, along with its documents.', + 'Profil mis à jour. Le statut, les commentaires et les documents sont conservés.' => 'Profile updated. The status, comments and documents are kept.', + 'Mise à jour refusée : %s' => 'Update rejected: %s', + 'L\'adresse email de ce fichier appartient à un autre profil.' => 'The email address in this file belongs to another profile.', + 'Télécharger le fichier' => 'Download file', + + // --- Modification + 'Modifier %s' => 'Edit %s', + 'Modifier la fiche' => 'Edit profile', + 'Chaque changement est tracé dans l\'historique. Pour corriger les expériences, les technologies ou les langues, mettez la fiche à jour depuis une nouvelle analyse.' => 'Every change is recorded in the history. To correct positions, technologies or languages, update the profile from a new analysis.', + 'Prénom' => 'First name', + 'Rue' => 'Street', + 'Code postal' => 'Postcode', + 'Ville' => 'City', + 'Pays' => 'Country', + '%d champ modifié.' => '%d field changed.', + '%d champs modifiés.' => '%d fields changed.', + 'Aucun changement.' => 'No changes.', + 'Le prénom et le nom sont obligatoires.' => 'First name and last name are required.', + 'Adresse email invalide.' => 'Invalid email address.', + 'Un autre profil utilise déjà cette adresse email.' => 'Another profile already uses this email address.', + + // --- Recherche + 'Recherche' => 'Search', + 'Recherche : %s' => 'Search: %s', + 'Tous les profils' => 'All profiles', + 'Langue' => 'Language', + 'Effacer' => 'Clear', + 'Exporter en CSV' => 'Export to CSV', + 'Séniorité déduite de la recherche : %s.' => 'Seniority inferred from the search: %s.', + 'Aucun profil ne correspond.' => 'No profile matches.', + 'Tous les mots doivent figurer sur la fiche. Retirez un mot, ou placez une expression entre guillemets pour la chercher telle quelle.' => 'Every word must appear on the profile. Remove a word, or put a phrase in quotation marks to search for it exactly.', + + // --- Export CSV + 'Référence' => 'Reference', + 'Années d\'expérience' => 'Years of experience', + 'Niveau d\'études' => 'Education level', + 'Reçu le' => 'Received on', + 'Conservation jusqu\'au' => 'Keep until', + + // --- Tableau de bord + 'Aucun profil pour l\'instant.' => 'No profiles yet.', + 'Les chiffres apparaîtront après le premier import.' => 'Figures will appear after the first import.', + 'Profils dans le vivier' => 'Profiles in the pool', + 'Reçus sur 30 jours' => 'Received in the last 30 days', + 'En cours de parcours' => 'In the pipeline', + 'Embauchés' => 'Hired', + 'Conservation à traiter' => 'Retention to review', + 'Profils par statut' => 'Profiles by status', + 'Temps moyen passé à chaque étape' => 'Average time spent at each stage', + 'Aucun changement de statut enregistré. Les délais se calculent à partir du premier passage d\'une étape à l\'autre.' => 'No status change recorded yet. Durations are computed from the first move between two stages.', + '%s j' => '%s d', + 'sur %d passage' => 'over %d move', + 'sur %d passages' => 'over %d moves', + 'Durée entre l\'entrée dans un statut et sa sortie. Les profils supprimés ne comptent plus.' => 'Time between entering a status and leaving it. Deleted profiles no longer count.', + 'Technologies les plus présentes' => 'Most common technologies', + 'Aucune technologie enregistrée.' => 'No technology recorded.', + + // --- Conservation + '%d profil à traiter' => '%d profile to review', + '%d profils à traiter' => '%d profiles to review', + 'Profils dont la durée de conservation est dépassée ou se termine dans 60 jours. Prolongez si le candidat a confirmé son accord, sinon supprimez. La durée par défaut est de %d mois.' => 'Profiles whose retention period has expired or ends within 60 days. Extend if the candidate has confirmed their consent, otherwise delete. The default period is %d months.', + 'Aucun profil n\'arrive à échéance.' => 'No profile is reaching its retention date.', + 'Profil' => 'Profile', + 'Échéance' => 'Due date', + 'dépassée de %d j' => '%d d overdue', + 'dans %d j' => 'in %d d', + 'Prolonger' => 'Extend', + 'Conservation prolongée de %d mois.' => 'Retention extended by %d months.', + + // --- Journal + '%d dernière action' => 'last %d action', + '%d dernières actions' => 'last %d actions', + 'Compte' => 'Account', + 'Filtrer' => 'Filter', + 'Aucune action enregistrée.' => 'No action recorded.', + 'Date' => 'Date', + 'Action' => 'Action', + 'Détail' => 'Details', + 'supprimé' => 'deleted', + 'Création' => 'Created', + 'Modification' => 'Edited', + 'Changement de statut' => 'Status changed', + 'Ajout de document' => 'Document added', + 'Suppression de document' => 'Document deleted', + 'Commentaire' => 'Comment', + 'Export' => 'Export', + 'Suppression' => 'Deleted', + 'Analyse' => 'Analysis', + 'Traduction' => 'Translation', + 'statut' => 'status', + 'Import de %s' => 'Import of %s', + 'Analyse automatique de %s' => 'Automatic analysis of %s', + 'Mise à jour depuis %s' => 'Update from %s', + 'Profil %s supprimé' => 'Profile %s deleted', + 'Liste CSV (%s profils)' => 'CSV list (%s profiles)', + 'Traduction en %s' => 'Translation into %s', + 'français' => 'French', + 'anglais' => 'English', + 'allemand' => 'German', + 'Photo ajoutée' => 'Photo added', + 'Photo retirée' => 'Photo removed', + 'CV IT Club FR (Word)' => 'IT Club CV FR (Word)', + 'CV IT Club FR (PDF)' => 'IT Club CV FR (PDF)', + 'CV anonymisé FR (Word)' => 'Anonymised CV FR (Word)', + 'CV anonymisé FR (PDF)' => 'Anonymised CV FR (PDF)', + 'CV IT Club EN (Word)' => 'IT Club CV EN (Word)', + 'CV IT Club EN (PDF)' => 'IT Club CV EN (PDF)', + 'CV anonymisé EN (Word)' => 'Anonymised CV EN (Word)', + 'CV anonymisé EN (PDF)' => 'Anonymised CV EN (PDF)', + 'CV IT Club DE (Word)' => 'IT Club CV DE (Word)', + 'CV IT Club DE (PDF)' => 'IT Club CV DE (PDF)', + 'CV anonymisé DE (Word)' => 'Anonymised CV DE (Word)', + 'CV anonymisé DE (PDF)' => 'Anonymised CV DE (PDF)', + 'CV Europass FR (Word)' => 'Europass CV FR (Word)', + 'CV Europass anonymisé FR (Word)' => 'Anonymised Europass CV FR (Word)', + 'CV Europass FR (PDF)' => 'Europass CV FR (PDF)', + 'CV Europass anonymisé FR (PDF)' => 'Anonymised Europass CV FR (PDF)', + 'CV Europass EN (Word)' => 'Europass CV EN (Word)', + 'CV Europass anonymisé EN (Word)' => 'Anonymised Europass CV EN (Word)', + 'CV Europass EN (PDF)' => 'Europass CV EN (PDF)', + 'CV Europass anonymisé EN (PDF)' => 'Anonymised Europass CV EN (PDF)', + 'CV Europass DE (Word)' => 'Europass CV DE (Word)', + 'CV Europass anonymisé DE (Word)' => 'Anonymised Europass CV DE (Word)', + 'CV Europass DE (PDF)' => 'Europass CV DE (PDF)', + 'CV Europass anonymisé DE (PDF)' => 'Anonymised Europass CV DE (PDF)', + + // --- Aperçu du CV et traduction + 'Retour à la fiche' => 'Back to profile', + 'Version' => 'Version', + 'Nominatif' => 'Named', + 'Anonymisé' => 'Anonymised', + 'Langue du CV' => 'CV language', + 'Télécharger en Word' => 'Download as Word', + 'Enregistrer en PDF' => 'Save as PDF', + 'La fiche a changé depuis la traduction : le contenu s\'affiche dans sa langue d\'origine.' => 'The profile has changed since it was translated: the content is shown in its original language.', + 'Seuls les intitulés sont traduits : le contenu reste dans la langue du CV d\'origine (%s).' => 'Only the headings are translated: the content stays in the language of the original CV (%s).', + 'Traduction en cours…' => 'Translating…', + 'Traduire le contenu' => 'Translate content', + 'Traduire par fichier' => 'Translate with a file', + 'Télécharger le fichier à traduire' => 'Download the file to translate', + 'Le déposer dans votre projet Claude d\'analyse, qui renvoie le fichier traduit.' => 'Drop it into your Claude analysis project, which returns the translated file.', + 'Charger le fichier traduit' => 'Upload the translated file', + 'Enregistrer la traduction' => 'Save translation', + 'Contenu traduit automatiquement le %s. Relisez-le avant d\'envoyer le CV.' => 'Content translated automatically on %s. Review it before sending the CV.', + 'Contenu traduit par fichier le %s.' => 'Content translated from a file on %s.', + 'Contenu traduit. Relisez-le avant d\'envoyer le CV.' => 'Content translated. Review it before sending the CV.', + 'Traduction enregistrée.' => 'Translation saved.', + 'La traduction automatique n\'est pas disponible.' => 'Automatic translation is not available.', + 'La traduction automatique n\'a pas abouti. Réessayez, ou passez par le fichier de traduction.' => 'Automatic translation did not complete. Try again, or use the translation file.', + 'La traduction reçue est illisible.' => 'The translation received cannot be read.', + 'La traduction reçue ne correspond pas à ce profil.' => 'The translation received does not match this profile.', + 'Ce fichier n\'est pas une traduction produite pour Vivier.' => 'This file is not a translation made for Vivier.', + 'Cette traduction concerne un autre profil (%s).' => 'This translation is for another profile (%s).', + 'La langue de cette traduction n\'est pas gérée.' => 'The language of this translation is not supported.', + 'Le fichier Word n\'a pas pu être créé.' => 'The Word file could not be created.', + + // --- Libellés du CV généré + 'Expérience' => 'Experience', + 'Localisation' => 'Location', + 'Compétences techniques' => 'Technical skills', + 'Expériences professionnelles' => 'Professional experience', + 'Formation' => 'Education', + 'Certifications' => 'Certifications', + 'Environnement :' => 'Environment:', + 'Environnement' => 'Environment', + 'Client : %s' => 'Client: %s', + 'Profil %s' => 'Profile %s', + 'Pour rencontrer ce consultant : %s' => 'To meet this consultant: %s', + 'TVA %s' => 'VAT %s', + 'Langages' => 'Programming languages', + 'Frameworks' => 'Frameworks', + 'Bases de données' => 'Databases', + 'Outils' => 'Tools', + 'Méthodes' => 'Methods', + 'Autres' => 'Other', + + // --- Noms de langues, tels que l'analyse les écrit + 'Français' => 'French', + 'Anglais' => 'English', + 'Allemand' => 'German', + 'Luxembourgeois' => 'Luxembourgish', + 'Néerlandais' => 'Dutch', + 'Espagnol' => 'Spanish', + 'Italien' => 'Italian', + 'Portugais' => 'Portuguese', + 'Arabe' => 'Arabic', + 'Russe' => 'Russian', + 'Polonais' => 'Polish', + 'Roumain' => 'Romanian', + 'Chinois' => 'Chinese', + 'Grec' => 'Greek', + 'Turc' => 'Turkish', + + // --- Version 1.4 : suivi, besoins clients, import en lot, sauvegarde + '%d CV attend son analyse. Elle démarre seule : gardez cette page ouverte.' => '%d CV is waiting to be analysed. It starts by itself: keep this page open.', + '%d CV attendent leur analyse. Elle démarre seule : gardez cette page ouverte.' => '%d CVs are waiting to be analysed. It starts by itself: keep this page open.', + '%d CV dans le lot' => '%d CVs in the batch', + '%d CV déposé.' => '%d CV added.', + '%d CV déposés.' => '%d CVs added.', + '%d besoin' => '%d request', + '%d besoins' => '%d requests', + '%d copie sur le serveur' => '%d copy on the server', + '%d copies sur le serveur' => '%d copies on the server', + '%d document' => '%d document', + '%d documents' => '%d documents', + '%d profil archivé' => '%d archived profile', + '%d profils archivés' => '%d archived profiles', + '%d profil déposé.' => '%d profile added.', + '%d profils déposés.' => '%d profiles added.', + '%d profil enregistré dans la colonne Reçu.' => '%d profile saved in the Received column.', + '%d profils enregistrés dans la colonne Reçu.' => '%d profiles saved in the Received column.', + '%d profil proposé' => '%d profile put forward', + '%d profils proposés' => '%d profiles put forward', + '%d profil ressemble à un profil existant : relisez-le avant de l\'enregistrer.' => '%d profile looks like an existing one: review it before saving.', + '%d profils ressemblent à des profils existants : relisez-les avant de les enregistrer.' => '%d profiles look like existing ones: review them before saving.', + '%d relance à faire' => '%d follow-up due', + '%d relances à faire' => '%d follow-ups due', + '%d semaine' => '%d week', + '%d semaines' => '%d weeks', + '%s (copie supprimée)' => '%s (copy deleted)', + '%s (créé)' => '%s (created)', + '%s (faite)' => '%s (done)', + '%s (mis à jour)' => '%s (updated)', + '%s (modifié)' => '%s (edited)', + '%s (profil retiré)' => '%s (profile removed)', + '%s (supprimé)' => '%s (deleted)', + '%s (supprimée)' => '%s (deleted)', + '%s (téléchargée)' => '%s (downloaded)', + '%s : analyse terminée.' => '%s: analysis complete.', + '%s : aucun profil JSON ne nomme ce fichier.' => '%s: no JSON profile names this file.', + '%s : date invalide.' => '%s: invalid date.', + '%s : le CV « %s » nommé dans ce profil n\'est pas parmi les fichiers déposés.' => '%s: the CV "%s" named in this profile is not among the files added.', + '%s : le lot est plein (%d CV au plus).' => '%s: the batch is full (%d CVs at most).', + '%s : nombre entier attendu, sans espace ni symbole.' => '%s: whole number expected, with no space or symbol.', + '%s : profil illisible ou sans nom de CV d\'origine.' => '%s: unreadable profile, or no original CV name in it.', + '%s : valeur inconnue.' => '%s: unknown value.', + '%s absent' => '%s missing', + '%s au moins' => '%s or above', + '%s brut par an' => '%s gross per year', + '%s par jour' => '%s per day', + 'Accord' => 'Consent', + 'Accord du %s' => 'Consent given on %s', + 'Accord enregistré.' => 'Consent recorded.', + 'Accord retiré' => 'Consent withdrawn', + 'Accord retiré.' => 'Consent withdrawn.', + 'Ajouter un entretien' => 'Add an interview', + 'Analyser' => 'Analyse', + 'Analyses terminées. La page se recharge.' => 'Analyses complete. The page is reloading.', + 'Archivage' => 'Archived', + 'Archive des fichiers' => 'Files archive', + 'Archiver le profil' => 'Archive the profile', + 'Archivé' => 'Archived', + 'Archivé le' => 'Archived on', + 'Archivés' => 'Archived', + 'Aucun' => 'None', + 'Aucun besoin avec ce statut.' => 'No request with this status.', + 'Aucun besoin enregistré.' => 'No request recorded.', + 'Aucun entretien enregistré.' => 'No interview recorded.', + 'Aucun entretien prévu. Un entretien se saisit sur la fiche d\'un profil.' => 'No interview planned. Interviews are added from a profile page.', + 'Aucun fichier reçu.' => 'No file received.', + 'Aucun profil ne porte les technologies ni les mots de l\'intitulé. Élargissez la demande, ou importez de nouveaux CV.' => 'No profile has the technologies or the words of the job title. Widen the request, or import new CVs.', + 'Aucun profil proposé. Choisissez-en dans la liste ci-dessous, ou depuis la fiche d\'un profil.' => 'No profile put forward. Pick some from the list below, or from a profile page.', + 'Aucun profil prêt à enregistrer.' => 'No profile ready to save.', + 'Aucune relance prévue.' => 'No follow-up planned.', + 'Aucune relance prévue. Une relance se crée sur la fiche d\'un profil.' => 'No follow-up planned. Follow-ups are created from a profile page.', + 'Aucune sauvegarde de la base n\'a encore été faite.' => 'No database backup has been made yet.', + 'Aucune sauvegarde pour l\'instant.' => 'No backup yet.', + 'Avis' => 'Assessment', + 'Avis inconnu.' => 'Unknown assessment.', + 'Besoin client' => 'Client request', + 'Besoin client concerné' => 'Related client request', + 'Besoin enregistré.' => 'Request saved.', + 'Besoin introuvable' => 'Request not found', + 'Besoin introuvable.' => 'Request not found.', + 'Besoin mis à jour.' => 'Request updated.', + 'Besoin supprimé.' => 'Request deleted.', + 'Besoins' => 'Requests', + 'Besoins clients' => 'Client requests', + 'Besoins ouverts' => 'Open requests', + 'Budget' => 'Budget', + 'Budget journalier' => 'Daily budget', + 'Budget journalier, en euros' => 'Daily budget, in euros', + 'C\'est une autre personne : enregistrer un nouveau profil' => 'This is someone else: save a new profile', + 'CV des candidats (PDF, DOCX, ODT, MD ou TXT)' => 'Candidates\' CVs (PDF, DOCX, ODT, MD or TXT)', + 'CV retiré du lot.' => 'CV removed from the batch.', + 'CV à analyser' => 'CVs to analyse', + 'Ce CV n\'est plus dans le lot.' => 'This CV is no longer in the batch.', + 'Ce candidat est déjà dans Vivier.' => 'This candidate is already in Vivier.', + 'Ce profil est archivé. Désarchivez-le pour changer son statut.' => 'This profile is archived. Unarchive it to change its status.', + 'Ce profil n\'est proposé pour aucun besoin.' => 'This profile is not put forward for any request.', + 'Client' => 'Client', + 'Compte rendu' => 'Notes', + 'Consentement' => 'Consent', + 'Consentement le' => 'Consent given on', + 'Contenu du lot' => 'Batch contents', + 'Copie supprimée du serveur.' => 'Copy deleted from the server.', + 'Copies gardées' => 'Copies kept', + 'Date de l\'accord' => 'Date of consent', + 'Demande' => 'Requirements', + 'Dernière réussie' => 'Last successful', + 'Dernière sauvegarde réussie il y a %d jours.' => 'Last successful backup %d days ago.', + 'Description' => 'Description', + 'Disponible après le début de la mission' => 'Available after the assignment starts', + 'Disponible le' => 'Available from', + 'Donner l\'avis et le compte rendu' => 'Add the assessment and notes', + 'Données commerciales' => 'Commercial details', + 'Données personnelles' => 'Personal data', + 'Début' => 'Start', + 'Début souhaité' => 'Requested start', + 'Défavorable' => 'Negative', + 'Déposer les CV' => 'Add the CVs', + 'Déposer les fichiers' => 'Add the files', + 'Déposez plusieurs CV d\'un coup : %d au plus. Chaque profil est relu avant d\'entrer dans le Kanban. Un CV laissé dans le lot est retiré après 7 jours.' => 'Add several CVs at once: %d at most. Each profile is reviewed before it enters the board. A CV left in the batch is removed after 7 days.', + 'Désarchivage' => 'Unarchived', + 'Désarchiver' => 'Unarchive', + 'Développeur Java senior' => 'Senior Java developer', + 'Email d\'essai remis au serveur. Vérifiez les boîtes de réception, courriers indésirables compris.' => 'Test email handed to the server. Check the inboxes, spam folders included.', + 'Email du candidat, formulaire, entretien' => 'Candidate\'s email, form, interview', + 'Email envoyé avec la sauvegarde en pièce jointe.' => 'Email sent with the backup attached.', + 'Email envoyé sans pièce jointe : le fichier dépasse la taille admise.' => 'Email sent without attachment: the file is over the size limit.', + 'Email non parti' => 'Email not sent', + 'Email sans pièce jointe' => 'Email without attachment', + 'En attente' => 'Waiting', + 'Enregistrer l\'accord du candidat' => 'Record the candidate\'s consent', + 'Enregistrer l\'entretien' => 'Save the interview', + 'Enregistrer le besoin' => 'Save the request', + 'Enregistrer le profil prêt' => 'Save the ready profile', + 'Enregistrer les %d profils prêts' => 'Save the %d ready profiles', + 'Enregistrer tous les profils prêts, sans les relire un par un ?' => 'Save all ready profiles, without reviewing them one by one?', + 'Entretien' => 'Interview', + 'Entretien client' => 'Client interview', + 'Entretien enregistré.' => 'Interview saved.', + 'Entretien le %s' => 'Interview on %s', + 'Entretien mis à jour.' => 'Interview updated.', + 'Entretien supprimé.' => 'Interview deleted.', + 'Entretien technique' => 'Technical interview', + 'Entretiens' => 'Interviews', + 'Entretiens des 30 prochains jours' => 'Interviews in the next 30 days', + 'Entretiens en cours' => 'Interviewing', + 'Entretiens passés sans avis' => 'Past interviews without assessment', + 'Entretiens sur 7 jours' => 'Interviews within 7 days', + 'Envoi' => 'Sent to', + 'Envoyer aussi la sauvegarde par email' => 'Also send the backup by email', + 'Envoyer un email d\'essai, sans pièce jointe' => 'Send a test email, without attachment', + 'Envoyée par email' => 'Sent by email', + 'Erreur' => 'Error', + 'Export des données personnelles' => 'Personal data export', + 'Exporter les données du candidat' => 'Export the candidate\'s data', + 'Favorable' => 'Positive', + 'Fermé' => 'Closed', + 'Fichier' => 'File', + 'Français, Anglais' => 'French, English', + 'Heure invalide.' => 'Invalid time.', + 'Heure, facultative' => 'Time, optional', + 'Historique des sauvegardes' => 'Backup history', + 'Il n\'apparaît plus dans le Kanban ni dans les chiffres. Ses données et ses documents sont conservés.' => 'It no longer shows on the board or in the figures. Its data and documents are kept.', + 'Import en lot' => 'Batch import', + 'Importer plusieurs CV' => 'Import several CVs', + 'Inclus' => 'Included', + 'Indifférente' => 'Any', + 'Indiquez l\'objet de la relance.' => 'Say what the follow-up is about.', + 'Intitulé du poste' => 'Job title', + 'L\'archive n\'a pas pu être créée.' => 'The archive could not be created.', + 'L\'email d\'essai n\'est pas parti. Vérifiez les adresses dans config/config.php.' => 'The test email was not sent. Check the addresses in config/config.php.', + 'L\'email n\'est pas parti. %s' => 'The email was not sent. %s', + 'La date de l\'accord ne peut pas être dans le futur.' => 'The date of consent cannot be in the future.', + 'La dernière sauvegarde a rencontré un problème.' => 'The last backup ran into a problem.', + 'La sauvegarde a échoué : %s' => 'The backup failed: %s', + 'La tâche planifiée de l\'hébergement lance cette sauvegarde chaque semaine et l\'envoie par email. Le fichier contient les données des candidats et n\'est pas chiffré : la protection repose sur celle des boîtes de réception.' => 'The hosting\'s scheduled task runs this backup every week and emails it. The file holds candidates\' data and is not encrypted: its protection is that of the inboxes.', + 'La tâche planifiée de l\'hébergement ne tourne pas, ou elle échoue. Lancez une sauvegarde ci-dessous, puis vérifiez la tâche dans l\'espace client OVH.' => 'The hosting\'s scheduled task is not running, or it fails. Run a backup below, then check the task in the OVH control panel.', + 'Langue absente de la fiche : %s' => 'Language missing from the profile: %s', + 'Langues demandées, séparées par des virgules' => 'Required languages, separated by commas', + 'Le client et l\'intitulé sont obligatoires.' => 'The client and the job title are required.', + 'Le lot est plein. Enregistrez ou retirez des CV pour en déposer d\'autres.' => 'The batch is full. Save or remove CVs to add more.', + 'Le profil sort du Kanban et des chiffres. Il reste consultable dans Profils, filtre « Archivés ».' => 'The profile leaves the board and the figures. It stays available in Profiles, "Archived" filter.', + 'Les CV, rapports, pièces jointes et photos sont trop lourds pour un email. Téléchargez leur archive et gardez-la avec la sauvegarde de la base du même jour.' => 'CVs, reports, attachments and photos are too heavy for an email. Download their archive and keep it with the database backup of the same day.', + 'Lieu' => 'Location', + 'Luxembourg, Metz, déplacements ponctuels' => 'Luxembourg, Metz, occasional travel', + 'Manuelle, %s' => 'Manual, %s', + 'Marquer comme faite' => 'Mark as done', + 'Masqués' => 'Hidden', + 'Mené par' => 'Conducted by', + 'Mobilité' => 'Mobility', + 'Modifier l\'accord' => 'Edit the consent', + 'Modifier le besoin' => 'Edit the request', + 'Non précisé' => 'Not specified', + 'Note' => 'Note', + 'Note de rapprochement sur 100' => 'Match score out of 100', + 'Note sur 100 : technologies 55, intitulé 15, séniorité 10, langues 10, disponibilité 5, tarif 5. Une donnée inconnue vaut la moitié des points. Les profils archivés ou non retenus sont exclus.' => 'Score out of 100: technologies 55, job title 15, seniority 10, languages 10, availability 5, rate 5. An unknown value earns half the points. Archived and rejected profiles are left out.', + 'Notez la date à laquelle le candidat a accepté que IT Club conserve son CV, et la façon dont il l\'a dit.' => 'Record the date the candidate agreed that IT Club keeps their CV, and how they said so.', + 'Nouveau besoin' => 'New request', + 'Origine' => 'Source', + 'Ouvert' => 'Open', + 'Ouvrir la page Sauvegarde' => 'Open the Backup page', + 'Pas encore d\'avis' => 'No assessment yet', + 'Passer le profil en « Entretiens en cours »' => 'Move the profile to "Interviewing"', + 'Pour mettre sa fiche à jour, ouvrez le profil existant, carte Gestion, « Mettre à jour depuis une nouvelle analyse ».' => 'To update their record, open the existing profile, Management card, "Update from a new analysis".', + 'Pourvu' => 'Filled', + 'Profil ajouté à ce besoin.' => 'Profile added to this request.', + 'Profil archivé le %s.' => 'Profile archived on %s.', + 'Profil archivé. Il reste dans Profils, filtre « Archivés ».' => 'Profile archived. It stays in Profiles, "Archived" filter.', + 'Profil désarchivé. Il est de retour dans le Kanban.' => 'Profile unarchived. It is back on the board.', + 'Profil retiré de ce besoin.' => 'Profile removed from this request.', + 'Profils (.json), CV et rapports' => 'Profiles (.json), CVs and reports', + 'Profils archivés' => 'Archived profiles', + 'Profils du vivier qui répondent au besoin' => 'Profiles in the pool that match the request', + 'Profils déjà analysés' => 'Profiles already analysed', + 'Profils proposés' => 'Profiles put forward', + 'Proposer' => 'Put forward', + 'Proposer pour un besoin ouvert' => 'Put forward for an open request', + 'Proposition mise à jour.' => 'Proposal updated.', + 'Présenté au client' => 'Presented to the client', + 'Prévoir la relance' => 'Plan the follow-up', + 'Prêt à relire' => 'Ready for review', + 'Rappeler après sa fin de mission' => 'Call back after their assignment ends', + 'Recueilli comment' => 'How it was obtained', + 'Relance' => 'Follow-up', + 'Relance enregistrée.' => 'Follow-up saved.', + 'Relance faite.' => 'Follow-up done.', + 'Relance supprimée.' => 'Follow-up deleted.', + 'Relancer l\'analyse' => 'Run the analysis again', + 'Relances' => 'Follow-ups', + 'Relances à faire' => 'Follow-ups due', + 'Relire' => 'Review', + 'Ressemble à %s' => 'Looks like %s', + 'Retenu par le client' => 'Selected by the client', + 'Retirer' => 'Remove', + 'Retirer de ce besoin' => 'Remove from this request', + 'Retirer l\'accord' => 'Withdraw the consent', + 'Retour au lot' => 'Back to the batch', + 'Réservé' => 'Mixed', + 'Réservées à IT Club. Elles ne figurent sur aucun CV exporté.' => 'For IT Club only. They never appear on an exported CV.', + 'Résultat' => 'Result', + 'Salaire annuel brut souhaité, en euros' => 'Expected gross annual salary, in euros', + 'Salaire souhaité' => 'Expected salary', + 'Sans email' => 'No email', + 'Sauvegarde' => 'Backup', + 'Sauvegarde en cours. Gardez la page ouverte.' => 'Backup in progress. Keep the page open.', + 'Sauvegarde en cours…' => 'Backing up…', + 'Sauvegarde faite : %s (%s).' => 'Backup done: %s (%s).', + 'Sauvegarde introuvable' => 'Backup not found', + 'Sauvegarder maintenant' => 'Back up now', + 'Seulement' => 'Only', + 'Statut du besoin' => 'Request status', + 'Suivi' => 'Follow-up', + 'Supprimer ce besoin et la liste des profils proposés ? Les profils eux-mêmes sont conservés.' => 'Delete this request and its list of profiles put forward? The profiles themselves are kept.', + 'Supprimer cet entretien' => 'Delete this interview', + 'Supprimer cet entretien ?' => 'Delete this interview?', + 'Supprimer cette copie du serveur ? Les copies reçues par email ne sont pas touchées.' => 'Delete this copy from the server? Copies received by email are not affected.', + 'Supprimer le besoin' => 'Delete the request', + 'Sur place' => 'On site', + 'Sur site' => 'On site', + 'Sélectionnez ensemble les fichiers JSON, les CV d\'origine et les rapports. Vivier associe chaque profil au CV que nomme son JSON.' => 'Select the JSON files, the original CVs and the reports together. Vivier pairs each profile with the CV its JSON names.', + 'Séniorité inférieure à la demande' => 'Seniority below the requirement', + 'Séniorité minimale' => 'Minimum seniority', + 'TJM %s' => 'Daily rate %s', + 'TJM souhaité' => 'Expected daily rate', + 'TJM souhaité au-dessus du budget' => 'Expected daily rate above the budget', + 'TJM souhaité, en euros' => 'Expected daily rate, in euros', + 'Taille' => 'Size', + 'Technologies demandées, séparées par des virgules' => 'Required technologies, separated by commas', + 'Type d\'entretien inconnu.' => 'Unknown interview type.', + 'Tâche planifiée' => 'Scheduled task', + 'Télécharger' => 'Download', + 'Télécharger l\'archive des fichiers' => 'Download the files archive', + 'Télétravail' => 'Remote work', + 'Télétravail complet' => 'Fully remote', + 'Télétravail partiel' => 'Partly remote', + 'Un besoin décrit une mission à pourvoir chez un client. Vivier propose les profils qui y répondent le mieux.' => 'A request describes an assignment to fill at a client. Vivier suggests the profiles that match it best.', + 'Un fichier zip avec tout ce que Vivier détient sur cette personne. À lui remettre s\'il demande l\'accès à ses données.' => 'A zip file with everything Vivier holds on this person. Hand it over if they ask for access to their data.', + 'Un profil ressemblant existe déjà.' => 'A similar profile already exists.', + 'Un profil ressemblant existe déjà. Cochez la case pour confirmer qu\'il s\'agit d\'une autre personne.' => 'A similar profile already exists. Tick the box to confirm this is someone else.', + 'Visioconférence' => 'Video call', + 'Vivier envoie chaque CV à Claude, l\'un après l\'autre. Comptez une à deux minutes par CV.' => 'Vivier sends each CV to Claude, one after the other. Allow one to two minutes per CV.', + 'adresses absentes de config/config.php' => 'addresses missing from config/config.php', + 'ajouté par %s le %s' => 'added by %s on %s', + 'aucune' => 'none', + 'aujourd\'hui' => 'today', + 'avis : %s' => 'assessment: %s', + 'avis à donner' => 'assessment needed', + 'besoin %s' => 'request %s', + 'copie supprimée' => 'copy deleted', + 'créé par %s le %s' => 'created by %s on %s', + 'créée par %s' => 'created by %s', + 'depuis %s' => 'from %s', + 'disponible le %s' => 'available from %s', + 'donné le %s' => 'given on %s', + 'début le %s' => 'starts on %s', + 'en retard de %d j' => '%d d overdue', + 'et les photos' => 'plus the photos', + 'faite le %s par %s' => 'done on %s by %s', + 'hors de ces chiffres.' => 'not counted in these figures.', + 'jusqu\'au %s' => 'until %s', + 'mené par %s' => 'conducted by %s', + 'même adresse email' => 'same email address', + 'même prénom et même nom' => 'same first name and name', + 'même téléphone' => 'same phone number', + 'non enregistré' => 'not recorded', + 'profil archivé' => 'archived profile', + 'saisi par %s' => 'entered by %s', + 'sans limite de durée' => 'no time limit', + 'À faire' => 'To do', + 'À présenter' => 'To present', + 'Écarté' => 'Set aside', + 'Échec' => 'Failed', + 'État' => 'Status', + 'à venir' => 'upcoming', + 'Sections de la fiche' => 'Profile sections', + 'Aucun profil' => 'No profile', + 'Fermer' => 'Close', + 'Exemple : %s' => 'Example: %s', + '%d action plus ancienne' => '%d older entry', + '%d actions plus anciennes' => '%d older entries', + 'Guide d\'utilisation' => 'User guide', + 'Guide d\'administration' => 'Administration guide', + 'Aide' => 'Help', + 'Langue du guide' => 'Guide language', + 'Imprimer ou enregistrer en PDF' => 'Print or save as PDF', + 'Sommaire' => 'Contents', + // --- Version 1.5 : corbeille, demandes de compte + 'Mise à la corbeille' => 'Moved to trash', + 'Restauration' => 'Restore', + 'Corbeille' => 'Trash', + 'Pas encore de compte ? Demander un accès' => 'No account yet? Request access', + 'Demander un accès' => 'Request access', + 'La suppression revient au gestionnaire des comptes.' => 'Only the account manager can delete.', + 'Un profil supprimé part à la corbeille, d\'où il est effacé après %d jours.' => 'A deleted profile goes to the trash and is erased after %d days.', + 'Effacement' => 'Erased on', + 'Effacer ce profil pour de bon, avec ses documents ? Cette action est sans retour.' => 'Erase this profile for good, along with its documents? This cannot be undone.', + 'Effacer maintenant' => 'Erase now', + 'La corbeille est vide.' => 'The trash is empty.', + 'Restaurer' => 'Restore', + 'Supprimé le' => 'Deleted on', + 'Un profil supprimé reste ici %d jours, puis Vivier l\'efface avec ses documents, sa photo, ses commentaires, ses entretiens, ses relances et ses mots-clés. Pendant ce délai, il n\'apparaît nulle part ailleurs. Les sauvegardes déjà faites gardent leur contenu.' => 'A deleted profile stays here for %d days, then Vivier erases it along with its documents, photo, comments, interviews, follow-ups and keywords. Until then it appears nowhere else. Backups already made keep their content.', + 'au prochain passage' => 'at the next run', + '%s (demande acceptée)' => '%s (request approved)', + '%s (demande refusée)' => '%s (request declined)', + '%s (désactivé)' => '%s (disabled)', + '%s (réactivé)' => '%s (re-enabled)', + '%s (à la corbeille depuis le %s)' => '%s (in the trash since %s)', + 'Suppression impossible : aucun compte à qui l\'attribuer.' => 'Cannot delete: no account to attribute the action to.', + 'Demandes de compte à valider' => 'Account requests to approve', + 'Ce profil est à la corbeille : le gestionnaire des comptes peut le restaurer, ou l\'effacer pour libérer son adresse email.' => 'This profile is in the trash: the account manager can restore it, or erase it to free its email address.', + 'profil à la corbeille' => 'profile in the trash', + 'à la corbeille' => 'in the trash', + '%d profil effacé : son délai de %d jours était passé.' => '%d profile erased: its %d-day period had ended.', + '%d profils effacés : leur délai de %d jours était passé.' => '%d profiles erased: their %d-day period had ended.', + 'Ce compte est désactivé. Adressez-vous au gestionnaire de Vivier.' => 'This account is disabled. Contact the Vivier account manager.', + 'Ce profil n\'est plus dans la corbeille.' => 'This profile is no longer in the trash.', + 'Cette demande n\'existe plus.' => 'This request no longer exists.', + 'Compte %s accepté. Prévenez la personne : elle peut se connecter.' => 'Account %s approved. Let the person know: they can sign in.', + 'Compte désactivé : il ne peut plus se connecter.' => 'Account disabled: it can no longer sign in.', + 'Compte introuvable.' => 'Account not found.', + 'Compte réactivé.' => 'Account re-enabled.', + 'Demande de %s refusée et effacée.' => 'Request from %s declined and erased.', + 'Profil %s effacé pour de bon, avec ses documents.' => 'Profile %s erased for good, along with its documents.', + 'Profil %s mis à la corbeille. Il sera effacé pour de bon dans %d jours.' => 'Profile %s moved to the trash. It will be erased for good in %d days.', + 'Profil %s restauré.' => 'Profile %s restored.', + 'Trop de demandes pour le moment. Réessayez plus tard ou adressez-vous au gestionnaire de Vivier.' => 'Too many requests for now. Try again later or contact the Vivier account manager.', + 'Votre demande de compte attend l\'accord du gestionnaire. Vous pourrez vous connecter ensuite.' => 'Your account request is waiting for the account manager\'s approval. You can sign in after that.', + 'Demande enregistrée' => 'Request recorded', + 'Demander un accès. Le gestionnaire valide chaque compte avant la première connexion.' => 'Request access. The account manager approves each account before its first sign-in.', + 'Envoyer la demande' => 'Send request', + 'J\'ai déjà un compte' => 'I already have an account', + 'Ne pas remplir ce champ' => 'Leave this field empty', + 'Retour à la connexion' => 'Back to sign-in', + 'Votre demande est transmise au gestionnaire de Vivier. Vous pourrez vous connecter dès qu\'il l\'aura acceptée. Aucun message automatique ne vous préviendra.' => 'Your request has been sent to the Vivier account manager. You can sign in once it is approved. No automatic message will tell you.', + 'Le profil part à la corbeille. Vous pouvez le restaurer pendant %d jours.' => 'The profile goes to the trash. You can restore it for %d days.', + 'Seul le gestionnaire des comptes peut supprimer un profil.' => 'Only the account manager can delete a profile.', + 'Supprimer ce profil ? Il part à la corbeille, puis il est effacé avec ses documents au bout de %d jours.' => 'Delete this profile? It goes to the trash, then it is erased with its documents after %d days.', + 'Accepter' => 'Approve', + 'Aucune demande en attente.' => 'No request pending.', + 'Ces personnes ne peuvent pas se connecter avant votre accord. Vivier ne les prévient pas : faites-le vous-même.' => 'These people cannot sign in before you approve. Vivier does not notify them: do it yourself.', + 'Chaque compte lit et modifie tous les profils. Une personne demande son compte depuis la page de connexion, ou vous le créez ici. Seul le gestionnaire accepte les demandes et supprime des profils.' => 'Every account reads and edits all profiles. A person requests an account from the sign-in page, or you create it here. Only the account manager approves requests and deletes profiles.', + 'Demande du %s' => 'Requested on %s', + 'Demandes à valider' => 'Account requests to approve', + 'Refuser' => 'Decline', + 'Refuser cette demande ? Elle est effacée.' => 'Decline this request? It is erased.', + + // --- Fiche profil en onglets (1.6) + 'le %s' => 'on %s', + 'Relances en retard et avis à donner' => 'Overdue follow-ups and feedback to give', + 'Missions' => 'Assignments', + 'Dossier' => 'File', + 'Conservation dépassée' => 'Retention period over', + '%d relance en retard' => '%d overdue follow-up', + '%d relances en retard' => '%d overdue follow-ups', + '%d avis d\'entretien à donner' => '%d interview feedback to give', + 'Prochain entretien : %s' => 'Next interview: %s', + 'Conservation dépassée depuis le %s' => 'Retention period over since %s', + 'Voir %d expérience plus ancienne' => 'Show %d older position', + 'Voir %d expériences plus anciennes' => 'Show %d older positions', + 'Voir %d autre technologie' => 'Show %d more technology', + 'Voir %d autres technologies' => 'Show %d more technologies', + 'Prévoir une relance' => 'Plan a follow-up', + 'Selon le CV' => 'According to the CV', + 'Aucun document.' => 'No documents.', + + // --- Finitions (1.7) + 'Date ferme. Elle sert au rapprochement avec les besoins.' => 'A firm date. It feeds the matching with client requests.', + 'Enregistré en capitales.' => 'Saved in capitals.', + 'Identité et CV' => 'Identity and CV', + 'L\'email, le téléphone et la rue ne figurent sur aucun CV. La ville et le pays apparaissent sur le CV nominatif.' => 'Email, phone and street never appear on a CV. City and country appear on the named CV.', + 'Préavis de 2 mois' => '2 months\' notice', + 'Telle qu\'affichée sur le CV.' => 'As shown on the CV.', + '%d relance prévue' => '%d follow-up planned', + '%d relances prévues' => '%d follow-ups planned', + 'dont %d à faire aujourd\'hui ou en retard' => '%d due today or overdue', + 'Filtres' => 'Filters', + 'Ces informations figurent sur le CV IT Club.' => 'This information appears on the IT Club CV.', + + // --- Sécurité des accès (1.8) + 'Continuer' => 'Continue', + 'Code' => 'Code', + 'Activer' => 'Turn on', + 'Étape suivante : le code de votre application d\'authentification.' => 'Next step: the code from your authenticator app.', + 'Étape suivante : la double authentification. Gardez votre téléphone à portée de main.' => 'Next step: two-step verification. Keep your phone at hand.', + 'Pas encore de compte ?' => 'No account yet?', + 'Comment Vivier protège vos accès' => 'How Vivier protects your access', + 'Saisissez le code à 6 chiffres affiché par votre application d\'authentification.' => 'Enter the 6-digit code shown by your authenticator app.', + 'Téléphone perdu ou pas sous la main ?' => 'Lost your phone or don\'t have it with you?', + 'Saisissez un de vos codes de secours dans le même champ, par exemple « abcd-efgh ». Chaque code ne sert qu\'une fois.' => 'Enter one of your backup codes in the same field, for example "abcd-efgh". Each code works once.', + 'Plus de codes de secours ? Le gestionnaire de Vivier remet votre double authentification à zéro : vous en activerez une nouvelle à la connexion suivante.' => 'No backup codes left? The Vivier account manager resets your two-step verification. You set it up again at your next sign-in.', + 'Vérification' => 'Verification', + 'Code de secours accepté. Il vous en reste %d.' => 'Backup code accepted. You have %d left.', + 'Générez-en de nouveaux dans Mon compte.' => 'Generate new ones in My account.', + 'Code incorrect. Vérifiez l\'heure de votre téléphone, ou utilisez un code de secours.' => 'Wrong code. Check the time on your phone, or use a backup code.', + 'Code incorrect. Vérifiez que l\'heure de votre téléphone est juste, puis saisissez le code affiché maintenant.' => 'Wrong code. Check that your phone shows the right time, then enter the code displayed now.', + 'Votre session est terminée. Reconnectez-vous.' => 'Your session has ended. Please sign in again.', + 'Vivier ne s\'utilise qu\'en HTTPS.' => 'Vivier only works over HTTPS.', + 'Double authentification' => 'Two-step verification', + 'Installez une application d\'authentification sur votre téléphone.' => 'Install an authenticator app on your phone.', + 'Google Authenticator, Microsoft Authenticator ou le gestionnaire de mots de passe que vous utilisez déjà (Bitwarden, 1Password, Mots de passe d\'Apple).' => 'Google Authenticator, Microsoft Authenticator, or the password manager you already use (Bitwarden, 1Password, Apple Passwords).', + 'Dans l\'application, ajoutez un compte et scannez ce QR code.' => 'In the app, add an account and scan this QR code.', + 'QR code à scanner' => 'QR code to scan', + 'Pas de QR code ? Saisissez cette clé à la main, en type « basé sur l\'heure » :' => 'Can\'t scan? Type this key by hand, as a "time-based" key:', + 'Saisissez le code à 6 chiffres que l\'application affiche.' => 'Enter the 6-digit code the app shows.', + 'Votre mot de passe' => 'Your password', + 'Configurer une autre application' => 'Set up another app', + 'Nouveau téléphone ou nouvelle application : l\'ancienne cessera de fonctionner dès que celle-ci sera activée. Vos autres sessions seront fermées et vous recevrez dix nouveaux codes de secours.' => 'New phone or new app: the old one stops working as soon as this one is on. Your other sessions close and you get ten new backup codes.', + 'Dernière étape : la double authentification. À chaque connexion, Vivier vous demandera votre mot de passe puis un code de votre téléphone.' => 'Last step: two-step verification. Each time you sign in, Vivier asks for your password, then a code from your phone.', + 'Pourquoi cette étape ?' => 'Why this step?', + 'Vos codes de secours' => 'Your backup codes', + 'Gardez ces dix codes en lieu sûr : gestionnaire de mots de passe, papier rangé. Chacun remplace une fois le code du téléphone, si vous le perdez ou l\'oubliez.' => 'Keep these ten codes somewhere safe: a password manager, or paper stored away. Each one replaces the phone code once, if you lose or forget your phone.', + 'Ils ne seront plus jamais affichés. Les codes précédents ne marchent plus.' => 'They will never be shown again. Your previous codes no longer work.', + 'Copier les codes' => 'Copy the codes', + 'Copiés' => 'Copied', + 'Copié' => 'Copied', + 'Copier' => 'Copy', + 'Imprimer' => 'Print', + 'J\'ai gardé mes codes, continuer' => 'I have saved my codes, continue', + 'Codes de secours' => 'Backup codes', + 'Le gestionnaire vous a donné ce mot de passe. Choisissez le vôtre : lui seul le connaîtra.' => 'The account manager gave you this password. Choose your own: only you will know it.', + 'Une phrase de plusieurs mots se retient mieux qu\'un mélange de signes. Vivier refuse les mots de passe connus dans des fuites de données.' => 'A phrase of several words is easier to remember than a jumble of symbols. Vivier rejects passwords found in known data breaches.', + 'Nouveau mot de passe' => 'New password', + 'Choisissez un mot de passe différent de celui que vous avez reçu.' => 'Choose a password different from the one you received.', + 'Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.' => 'This password appears in known data breaches. Choose another one.', + 'Le mot de passe compte au plus %d caractères.' => 'The password can have at most %d characters.', + 'Votre mot de passe est incorrect : action annulée.' => 'Your password is wrong: action cancelled.', + 'Chiffrement impossible.' => 'Encryption failed.', + 'La clé « cle_secrets » de la configuration est invalide : 32 octets en base64 attendus.' => 'The "cle_secrets" key in the configuration is invalid: 32 bytes in base64 expected.', + 'Vivier ne peut pas écrire dans le dossier config/. Ajoutez la clé « cle_secrets » dans config/config.php (voir config.exemple.php).' => 'Vivier cannot write to the config/ folder. Add the "cle_secrets" key to config/config.php (see config.exemple.php).', + + // Invitation + 'Obtenir un accès' => 'Getting access', + 'Vivier ne propose pas d\'inscription libre. Le gestionnaire de Vivier vous envoie un lien d\'invitation personnel, valable 48 heures et utilisable une seule fois.' => 'Vivier has no open sign-up. The Vivier account manager sends you a personal invitation link. It works once and expires after 48 hours.', + 'Avec ce lien, vous choisissez votre identifiant et votre mot de passe, puis vous activez la double authentification sur votre téléphone.' => 'With this link, you choose your username and password, then set up two-step verification on your phone.', + 'Pour recevoir un lien, adressez-vous au gestionnaire de Vivier chez IT Club.' => 'To get a link, contact the Vivier account manager at IT Club.', + 'Rejoindre Vivier' => 'Join Vivier', + 'Lien d\'invitation invalide' => 'Invalid invitation link', + 'Ce lien a expiré, a déjà servi ou a été annulé. Demandez un nouveau lien au gestionnaire de Vivier.' => 'This link has expired, has already been used or was cancelled. Ask the Vivier account manager for a new one.', + 'Créez votre compte. Le lien sert une seule fois, jusqu\'au %s.' => 'Create your account. The link works once, until %s.', + '3 à 40 caractères : lettres sans accent, chiffres, point, tiret.' => '3 to 40 characters: unaccented letters, digits, dot, hyphen.', + 'Ce lien d\'invitation vient d\'être utilisé.' => 'This invitation link has just been used.', + + // Mon compte + 'Identité' => 'Identity', + 'Rôle' => 'Role', + 'Votre nom et votre identifiant se changent dans Gestion, Comptes.' => 'Change your name and username in Manage, Accounts.', + 'Pour changer de nom ou d\'identifiant, adressez-vous au gestionnaire de Vivier.' => 'To change your name or username, contact the Vivier account manager.', + 'Activée le %s' => 'On since %s', + '%d restant' => '%d left', + '%d restants' => '%d left', + 'Générer de nouveaux codes de secours' => 'Generate new backup codes', + 'Les dix nouveaux codes remplacent les anciens, qui ne marcheront plus.' => 'The ten new codes replace the old ones, which will stop working.', + 'Générer les codes' => 'Generate the codes', + 'Configurer une autre application (nouveau téléphone)' => 'Set up another app (new phone)', + 'Vivier refuse les mots de passe connus dans des fuites de données. Vos autres sessions se fermeront.' => 'Vivier rejects passwords found in known data breaches. Your other sessions will close.', + 'Sessions et connexions' => 'Sessions and sign-ins', + 'Une session se ferme après 2 heures sans activité, et au plus tard 12 heures après la connexion.' => 'A session closes after 2 hours of inactivity, and at the latest 12 hours after sign-in.', + 'Déconnecter mes autres sessions' => 'Sign out my other sessions', + 'Utile si vous êtes resté connecté sur un autre ordinateur ou un téléphone perdu.' => 'Useful if you stayed signed in on another computer or a lost phone.', + 'Vos dernières connexions' => 'Your latest sign-ins', + 'Une connexion que vous ne reconnaissez pas ? Changez votre mot de passe et prévenez le gestionnaire de Vivier.' => 'A sign-in you don\'t recognise? Change your password and tell the Vivier account manager.', + 'Mot de passe modifié. Vos autres sessions sont fermées.' => 'Password changed. Your other sessions are closed.', + 'Vos autres sessions sont fermées. Celle-ci reste ouverte.' => 'Your other sessions are closed. This one stays open.', + 'programme' => 'script', + 'inconnu' => 'unknown', + + // Comptes + 'Journal des connexions' => 'Sign-in log', + 'Chaque compte lit et modifie tous les profils. Une personne rejoint Vivier par un lien d\'invitation, ou vous créez son compte ici. À la première connexion, chacun active la double authentification.' => 'Every account reads and edits all profiles. A person joins Vivier through an invitation link, or you create their account here. At first sign-in, everyone sets up two-step verification.', + 'Inviter une personne' => 'Invite someone', + 'Lien d\'invitation' => 'Invitation link', + 'Lien d\'invitation pour %s' => 'Invitation link for %s', + 'Copiez-le et envoyez-le à la personne, par email ou messagerie interne. Il ne sera plus affiché. Il sert une fois et expire dans %d heures.' => 'Copy it and send it to the person, by email or internal chat. It will not be shown again. It works once and expires in %d hours.', + 'Nom de la personne, facultatif' => 'Person\'s name, optional', + 'Créer un lien d\'invitation' => 'Create an invitation link', + 'La personne choisit son identifiant et son mot de passe, puis active la double authentification. Son compte est actif tout de suite.' => 'The person chooses a username and password, then sets up two-step verification. The account is active right away.', + 'Liens en cours' => 'Open links', + 'Sans nom' => 'No name', + 'créé le %s par %s, expire le %s' => 'created on %s by %s, expires on %s', + 'Annuler ce lien ? Il ne fonctionnera plus.' => 'Cancel this link? It will stop working.', + 'Annuler le lien' => 'Cancel the link', + 'Invitation annulée : le lien ne fonctionne plus.' => 'Invitation cancelled: the link no longer works.', + 'Cette invitation n\'existe plus.' => 'This invitation no longer exists.', + 'Demandes faites avant la version 1.8, par l\'ancienne page publique. Elles ne donnent aucun accès avant votre accord. Vivier ne prévient pas la personne : faites-le vous-même.' => 'Requests made before version 1.8, through the old public page. They give no access until you approve. Vivier does not notify the person: do it yourself.', + 'Connexions bloquées' => 'Blocked sign-ins', + 'Après 5 échecs, un identifiant est bloqué 15 minutes depuis l\'adresse en cause, puis 30 et 60 minutes. Une adresse qui accumule 20 échecs en une heure est bloquée pour tous les identifiants. Débloquez seulement si vous savez qui essaie.' => 'After 5 failures, a username is blocked for 15 minutes from the address involved, then 30 and 60 minutes. An address with 20 failures in an hour is blocked for every username. Unblock only if you know who is trying.', + 'Bloqué' => 'Blocked', + 'Adresse IP' => 'IP address', + 'Échecs sur 24 h' => 'Failures in 24 h', + 'Jusqu\'à' => 'Until', + 'Identifiant %s' => 'Username %s', + 'Toute l\'adresse' => 'Whole address', + 'Débloquer' => 'Unblock', + 'Blocage levé. La connexion est de nouveau possible.' => 'Block lifted. Signing in works again.', + 'Préférez le lien d\'invitation : personne d\'autre que la personne ne connaît alors son mot de passe. Un compte créé ici change son mot de passe à la première connexion.' => 'Prefer the invitation link: then nobody but the person knows the password. An account created here changes its password at first sign-in.', + 'Mot de passe provisoire, %d caractères au moins' => 'Temporary password, at least %d characters', + 'Compte créé. Transmettez l\'identifiant et le mot de passe à la personne : elle le changera à sa première connexion, puis activera la double authentification.' => 'Account created. Give the username and password to the person: they change it at first sign-in, then set up two-step verification.', + 'Double authentification active' => 'Two-step verification on', + 'Double authentification à activer' => 'Two-step verification to set up', + 'Mot de passe à changer' => 'Password to change', + 'Modifier le nom, l\'identifiant ou le mot de passe' => 'Change the name, username or password', + 'Nouveau mot de passe provisoire, à laisser vide pour le garder' => 'New temporary password, leave empty to keep the current one', + 'Pour votre propre mot de passe, passez plutôt par Mon compte.' => 'For your own password, use My account instead.', + 'La personne le changera à sa prochaine connexion. Ses sessions ouvertes se ferment.' => 'The person changes it at next sign-in. Their open sessions close.', + 'Votre mot de passe, seulement pour un nouveau mot de passe' => 'Your password, only for a new password', + 'Votre mot de passe, pour confirmer' => 'Your password, to confirm', + 'Remettre à zéro la double authentification' => 'Reset two-step verification', + 'Pour une personne qui a perdu son téléphone et ses codes de secours. Elle activera une nouvelle application à sa prochaine connexion. Vérifiez d\'abord son identité, par téléphone ou en personne.' => 'For someone who lost their phone and backup codes. They set up a new app at next sign-in. Check who they are first, by phone or in person.', + 'Remettre à zéro' => 'Reset', + 'Double authentification remise à zéro. La personne en active une nouvelle à sa prochaine connexion. Ses sessions ouvertes sont fermées.' => 'Two-step verification reset. The person sets it up again at next sign-in. Their open sessions are closed.', + 'Un gestionnaire crée et modifie les comptes, supprime des profils, voit le journal, les connexions et les sauvegardes.' => 'An account manager creates and edits accounts, deletes profiles, and sees the activity log, sign-ins and backups.', + 'Confier' => 'Grant', + 'Gestion des comptes confiée.' => 'Account management granted.', + 'Gestion des comptes retirée.' => 'Account management removed.', + 'Désactiver ce compte ? Ses sessions se ferment tout de suite.' => 'Disable this account? Its sessions close right away.', + 'Compte désactivé : il ne peut plus se connecter et ses sessions sont fermées.' => 'Account disabled: it can no longer sign in and its sessions are closed.', + 'Ses connexions' => 'Sign-ins', + 'Maintenance' => 'Maintenance', + 'Traduction des profils' => 'Profile translation', + 'Chaque profil est traduit dans les langues de l\'interface, en arrière-plan, tant qu\'une page de Vivier reste ouverte. À lancer une fois après la mise à jour 1.8.' => 'Each profile is translated into the interface languages, in the background, while a Vivier page stays open. Run once after the 1.8 update.', + 'L\'analyse automatique n\'est pas configurée : les profils se traduisent un par un, par fichier, depuis l\'aperçu du CV IT Club.' => 'Automatic analysis is not set up: translate profiles one by one, by file, from the IT Club CV preview.', + '%d traduction à faire.' => '%d translation to do.', + '%d traductions à faire.' => '%d translations to do.', + '%d traduction à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.' => '%d translation to do. It runs in the background while a Vivier page stays open.', + '%d traductions à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.' => '%d translations to do. They run in the background while a Vivier page stays open.', + '%d traduction abandonnée après 3 essais :' => '%d translation dropped after 3 attempts:', + '%d traductions abandonnées après 3 essais :' => '%d translations dropped after 3 attempts:', + 'Tous les profils sont déjà traduits.' => 'All profiles are already translated.', + 'Traduire tous les profils' => 'Translate all profiles', + 'L\'archive contient tous les CV : Vivier vérifie que c\'est bien vous.' => 'The archive holds every CV: Vivier checks that it is really you.', + + // Journal des connexions et des erreurs + 'Connexions' => 'Sign-ins', + '%d ligne' => '%d row', + '%d lignes' => '%d rows', + 'Chaque essai de connexion, réussi ou non, avec l\'adresse IP et le navigateur. Gardé %d mois. Un compte gestionnaire qui se connecte depuis un réseau jamais vu déclenche un email d\'alerte.' => 'Every sign-in attempt, successful or not, with the IP address and browser. Kept for %d months. An account manager signing in from a never-seen network triggers an alert email.', + 'Échecs seulement' => 'Failures only', + 'Aucune connexion enregistrée.' => 'No sign-ins recorded.', + 'Navigateur' => 'Browser', + 'identifiant inconnu' => 'unknown username', + 'Connexion réussie' => 'Signed in', + 'Mot de passe incorrect' => 'Wrong password', + 'Code incorrect' => 'Wrong code', + 'Essai bloqué' => 'Attempt blocked', + 'Compte désactivé' => 'Account disabled', + 'Demande en attente' => 'Request pending', + 'Erreurs' => 'Errors', + 'Les erreurs imprévues des %d derniers incidents. Une personne qui voit « Une erreur est survenue » reçoit une référence : cherchez-la ici. Aucune donnée de candidat n\'y figure.' => 'Unexpected errors, last %d incidents. Someone who sees "Something went wrong" gets a reference: look it up here. No candidate data is stored.', + 'Aucune erreur notée.' => 'No errors recorded.', + 'Page' => 'Page', + 'Cause' => 'Cause', + 'Endroit du code' => 'Code location', + 'Vider le journal' => 'Clear the log', + 'Vider le journal des erreurs ?' => 'Clear the error log?', + 'Journal des erreurs vidé.' => 'Error log cleared.', + 'Une erreur est survenue (référence %s).' => 'Something went wrong (reference %s).', + 'Référence à lui donner : %s' => 'Reference to give them: %s', + 'Sécurité' => 'Security', + + // Historique + 'Invitation créée : %s' => 'Invitation created: %s', + 'Invitation annulée' => 'Invitation cancelled', + '%s (compte créé par invitation)' => '%s (account created by invitation)', + '%s (compte créé)' => '%s (account created)', + '%s (mot de passe changé)' => '%s (password changed)', + '%s (double authentification remise à zéro)' => '%s (two-step verification reset)', + '%s (gestion des comptes confiée)' => '%s (account management granted)', + '%s (gestion des comptes retirée)' => '%s (account management removed)', + '%s (connexion débloquée)' => '%s (sign-in unblocked)', + '%s : nouvelle application d\'authentification' => '%s: new authenticator app', + '%s : mot de passe changé' => '%s: password changed', + '%s : nouveaux codes de secours' => '%s: new backup codes', + '%s : autres sessions fermées' => '%s: other sessions closed', + 'Renouvellement du titre de séjour : il expire le %s' => 'Residence permit renewal: it expires on %s', + + // --- Traduction des profils (1.8) + 'en français' => 'into French', + 'en anglais' => 'into English', + 'Vous lisez le texte d\'origine de ce profil.' => 'You are reading this profile\'s original text.', + 'Voir la traduction %s' => 'View the translation %s', + 'Ce profil n\'est pas encore traduit %s : le texte s\'affiche dans sa langue d\'origine.' => 'This profile is not translated %s yet: the text shows in its original language.', + 'La fiche a changé depuis sa traduction : le texte s\'affiche dans sa langue d\'origine.' => 'The profile changed since its translation: the text shows in its original language.', + 'La traduction se fait en arrière-plan.' => 'The translation runs in the background.', + 'Traduire maintenant' => 'Translate now', + 'Texte traduit automatiquement le %s.' => 'Text translated automatically on %s.', + 'Texte traduit le %s.' => 'Text translated on %s.', + 'Voir l\'original' => 'View the original', + 'Profil traduit. Relisez la traduction : elle est automatique.' => 'Profile translated. Review it: it is automatic.', + + // --- Disponibilité, nationalité, permis de travail (1.8) + 'Immédiate' => 'Immediate', + 'Sous 1 mois' => 'Within 1 month', + 'Sous 2 mois' => 'Within 2 months', + 'Sous 3 mois' => 'Within 3 months', + 'Date précise' => 'Specific date', + 'À préciser' => 'To be confirmed', + 'À partir du' => 'From', + 'À partir du %s' => 'From %s', + 'vers le %s' => 'around %s', + 'Disponibilité : %s' => 'Availability: %s', + 'Disponibilité : indiquez la date.' => 'Availability: enter the date.', + '« Sous N mois » donne une date estimée, utilisée pour les besoins clients.' => '"Within N months" gives an estimated date, used for client requests.', + 'Précision libre, en interne : la formulation du CV, un préavis à négocier.' => 'Free internal note: the CV wording, a notice period to negotiate.', + 'Préavis de 2 mois, négociable' => '2 months\' notice, negotiable', + 'Note sur la disponibilité' => 'Availability note', + 'Aucune' => 'None', + 'Non renseignée' => 'Not specified', + 'Nationalité' => 'Nationality', + 'Autre nationalité, facultative' => 'Other nationality, optional', + 'Nationalité et permis de travail' => 'Nationality and work permit', + 'Sert à savoir si un permis de travail est nécessaire au Luxembourg. Ne figure sur aucun CV.' => 'Tells whether a work permit is needed in Luxembourg. Never appears on a CV.', + 'Elles ne figurent pas sur le CV IT Club. Le CV Europass nominatif les montre, sauf si vous les retirez de l\'aperçu.' => 'They do not appear on the IT Club CV. The named Europass CV shows them, unless you remove them in the preview.', + 'Permis de travail' => 'Work permit', + 'Permis de travail non requis : nationalité de l\'Union européenne, de l\'Espace économique européen ou suisse.' => 'No work permit needed: EU, EEA or Swiss nationality.', + 'Texte libre : type de titre, restrictions, démarches en cours.' => 'Free text: type of permit, restrictions, pending steps.', + 'Autorisation de travail salarié, limitée au secteur IT' => 'Salaried work permit, limited to the IT sector', + 'Titre valable jusqu\'au' => 'Permit valid until', + 'Titre valable jusqu\'au %s' => 'Permit valid until %s', + 'Vivier prévoit une relance 2 mois avant.' => 'Vivier plans a follow-up 2 months before.', + 'Non requis' => 'Not required', + 'UE, EEE ou Suisse' => 'EU, EEA or Switzerland', + 'Hors UE' => 'Non-EU', + 'Titre de séjour' => 'Residence permit', + 'Expiré ou expire sous 2 mois' => 'Expired or expiring within 2 months', + 'Titre de séjour : expire le %s' => 'Residence permit: expires on %s', + 'Titre de séjour expiré' => 'Residence permit expired', + 'Titre de séjour expiré le %s' => 'Residence permit expired on %s', + 'Ces informations figurent sur les CV IT Club et Europass.' => 'This information appears on the IT Club and Europass CVs.', + 'code pays ISO à deux lettres attendu (FR, MA).' => 'two-letter ISO country code expected (FR, MA).', + 'date obligatoire quand disponibilite_code vaut « date ».' => 'date required when disponibilite_code is "date".', + + // --- CV Europass (1.8) + 'CV Europass' => 'Europass CV', + 'Ces rubriques n\'apparaissent que sur le CV Europass, et seulement si elles sont remplies.' => 'These sections only appear on the Europass CV, and only when filled in.', + 'Bénévolat' => 'Volunteering', + 'Bénévolat et permis' => 'Volunteering and licence', + 'Une activité par ligne' => 'One activity per line', + 'Permis de conduire' => 'Driving licence', + 'Permis de conduire :' => 'Driving licence:', + 'Catégories séparées par des virgules.' => 'Categories separated by commas.', + 'Projet' => 'Project', + 'Projet : %s' => 'Project: %s', + 'Mission' => 'Assignment', + 'Charge' => 'Workload', + 'Charge : %s' => 'Workload: %s', + 'À propos de moi' => 'About me', + 'Éducation et formation' => 'Education and training', + 'Expérience professionnelle' => 'Work experience', + 'Compétences linguistiques' => 'Language skills', + 'Langue(s) maternelle(s) :' => 'Mother tongue(s):', + 'Autre(s) langue(s) :' => 'Other language(s):', + 'Compréhension' => 'Understanding', + 'Expression orale' => 'Speaking', + 'Écrit' => 'Writing', + 'Compréhension orale' => 'Listening', + 'Compréhension écrite' => 'Reading', + 'Expression orale en continu' => 'Spoken production', + 'Interaction orale' => 'Spoken interaction', + 'Niveaux : A1 et A2 : utilisateur de base ; B1 et B2 : utilisateur indépendant ; C1 et C2 : utilisateur expérimenté' => 'Levels: A1 and A2: basic user; B1 and B2: independent user; C1 and C2: proficient user', + 'Niveau' => 'Level', + 'Compétences' => 'Skills', + 'Rôle et réalisations principales :' => 'Main role and achievements:', + 'Adresse électronique' => 'Email address', + 'Numéro de téléphone' => 'Phone number', + 'Technique' => 'Technical', + 'Logiciels et outils' => 'Software and tools', + 'Traduire par fichier, depuis l\'aperçu du CV IT Club' => 'Translate by file, from the IT Club CV preview', +]; diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..053acca --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,19 @@ +Options -Indexes +DirectoryIndex index.php + +# HTTPS obligatoire : toute adresse en http:// est redirigée vers https:// + + RewriteEngine On + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP:X-Forwarded-Proto} !=https + RewriteCond %{HTTP_HOST} !^(localhost|127\.0\.0\.1)(:\d+)?$ [NC] + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + + + Header always set X-Content-Type-Options "nosniff" + # Rien de ce site ne doit figurer dans un moteur de recherche. Pas de blocage par robots.txt : + # un moteur qui ne peut pas lire une page ne voit pas cette consigne et peut en afficher l'adresse. + Header always set X-Robots-Tag "noindex, nofollow, noarchive" + # Le navigateur retient pendant un an qu'il ne doit venir qu'en HTTPS (les pages PHP l'envoient aussi) + Header always set Strict-Transport-Security "max-age=31536000" env=HTTPS + diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..7d73c70 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,480 @@ +(function () { + 'use strict'; + + var csrf = document.querySelector('meta[name="csrf"]').content; + var textes = JSON.parse(document.querySelector('meta[name="textes"]').content); + + // ------------------------------------------------------------------------- + // Menu des écrans étroits + // ------------------------------------------------------------------------- + var boutonMenu = document.querySelector('.menu-bouton'); + if (boutonMenu) { + var menu = document.getElementById('menu'); + boutonMenu.addEventListener('click', function () { + var ouvert = menu.classList.toggle('ouvert'); + boutonMenu.setAttribute('aria-expanded', ouvert ? 'true' : 'false'); + }); + } + + // Sous-menus (Gestion, compte, CV IT Club) : un seul ouvert à la fois, fermés au clic ailleurs et à la touche Échap + var sousMenus = Array.prototype.slice.call(document.querySelectorAll('.menu-plus, .menu-deroulant')); + sousMenus.forEach(function (m) { + m.addEventListener('toggle', function () { + if (m.open) { sousMenus.forEach(function (autre) { if (autre !== m) { autre.open = false; } }); } + }); + }); + document.addEventListener('click', function (e) { + sousMenus.forEach(function (m) { if (m.open && !m.contains(e.target)) { m.open = false; } }); + }); + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') { return; } + sousMenus.forEach(function (m) { if (m.open) { m.open = false; m.querySelector('summary').focus(); } }); + }); + + // ------------------------------------------------------------------------- + // Envoi long : le bouton annonce l'attente et empêche un second envoi + // ------------------------------------------------------------------------- + document.querySelectorAll('form[data-attente]').forEach(function (form) { + form.addEventListener('submit', function () { + var bouton = form.querySelector('button[type="submit"]'); + bouton.disabled = true; + bouton.textContent = form.dataset.attenteBouton || textes.analyseEnCours; + var p = document.createElement('p'); + p.className = 'flash flash-info'; + p.setAttribute('role', 'status'); + p.textContent = form.dataset.attente; + form.appendChild(p); + }); + }); + + // ------------------------------------------------------------------------- + // Kanban : glisser-déposer entre colonnes + // ------------------------------------------------------------------------- + var kanban = document.querySelector('.kanban'); + if (kanban && window.Sortable) { + var dialogue = document.getElementById('dialogue-motif'); + var annonce = document.getElementById('annonce'); + + var compter = function () { + kanban.querySelectorAll('.colonne').forEach(function (col) { + col.querySelector('.compteur').textContent = col.querySelectorAll('.fiche').length; + }); + }; + + var annuler = function (evt, message) { + evt.from.insertBefore(evt.item, evt.from.children[evt.oldIndex] || null); + compter(); + if (message) { + annonce.textContent = message; + annonce.hidden = false; + } + }; + + var envoyer = function (evt, motif) { + var corps = new URLSearchParams(); + corps.set('csrf', csrf); + corps.set('id', evt.item.dataset.id); + corps.set('statut', evt.to.dataset.statut); + corps.set('ordre', Array.prototype.map.call(evt.to.children, function (li) { return li.dataset.id; }).join(',')); + if (motif) { corps.set('motif', motif); } + + fetch(kanban.dataset.url, { + method: 'POST', + headers: { 'X-Requested-With': 'fetch' }, + body: corps, + credentials: 'same-origin' + }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok && j.ok, erreur: j.erreur }; }); }) + .then(function (res) { + if (!res.ok) { annuler(evt, res.erreur || textes.echecEnregistrement); return; } + annonce.hidden = true; + var ancien = evt.item.querySelector('.motif'); + if (ancien) { ancien.remove(); } + if (motif) { + var p = document.createElement('p'); + p.className = 'motif'; + p.textContent = motif; + evt.item.appendChild(p); + } + }) + .catch(function () { annuler(evt, textes.connexionPerdue); }); + }; + + var demanderMotif = function (evt) { + var zone = dialogue.querySelector('textarea'); + zone.value = ''; + dialogue.returnValue = ''; + dialogue.addEventListener('close', function () { + var motif = zone.value.trim(); + if (dialogue.returnValue === 'valider' && motif) { envoyer(evt, motif); } else { annuler(evt); } + }, { once: true }); + dialogue.showModal(); + }; + + dialogue.querySelector('button[value="annuler"]').addEventListener('click', function () { + dialogue.close('annuler'); + }); + + kanban.querySelectorAll('.cartes').forEach(function (liste) { + Sortable.create(liste, { + group: 'kanban', + animation: 150, + // Sur écran tactile, un appui bref fait défiler la page ; un appui maintenu saisit la carte + delay: 180, + delayOnTouchOnly: true, + disabled: kanban.dataset.modifiable !== '1', + ghostClass: 'fantome', + chosenClass: 'saisie', + dragClass: 'glisse', + onStart: function () { kanban.classList.add('en-glisse'); }, + onEnd: function (evt) { + kanban.classList.remove('en-glisse'); + if (evt.from === evt.to && evt.oldIndex === evt.newIndex) { return; } + compter(); + if (evt.to.dataset.statut === 'non_retenu' && evt.from !== evt.to) { + demanderMotif(evt); + } else { + envoyer(evt, null); + } + } + }); + }); + } + + // ------------------------------------------------------------------------- + // Kanban : filtre instantané des cartes + // ------------------------------------------------------------------------- + var filtre = document.getElementById('filtre-kanban'); + if (filtre && kanban) { + var sansAccent = function (t) { return t.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); }; + var texte = document.getElementById('filtre-texte'); + var seniorite = document.getElementById('filtre-seniorite'); + var filtrer = function () { + var mots = sansAccent(texte.value).split(/\s+/).filter(Boolean); + kanban.querySelectorAll('.colonne').forEach(function (col) { + var visibles = 0; + col.querySelectorAll('.fiche').forEach(function (fiche) { + var contenu = sansAccent(fiche.textContent); + var garde = (!seniorite.value || fiche.dataset.seniorite === seniorite.value) && + mots.every(function (m) { return contenu.indexOf(m) !== -1; }); + fiche.classList.toggle('cachee', !garde); + if (garde) { visibles++; } + }); + col.querySelector('.compteur').textContent = visibles; + }); + }; + filtre.addEventListener('submit', function (e) { e.preventDefault(); }); + texte.addEventListener('input', filtrer); + seniorite.addEventListener('change', filtrer); + } + + // ------------------------------------------------------------------------- + // Import en lot : les CV en attente sont analysés l'un après l'autre, une requête par CV + // ------------------------------------------------------------------------- + var lot = document.getElementById('lot'); + if (lot && lot.dataset.auto === '1') { + var enAttente = Array.prototype.slice.call(lot.querySelectorAll('tr[data-etat="en_attente"]')); + var avancement = document.getElementById('lot-avancement'); + var total = enAttente.length; + enAttente.forEach(function (tr) { + var b = tr.querySelector('.bouton-analyse'); + if (b) { b.hidden = true; } + }); + var analyserSuivant = function () { + var tr = enAttente.shift(); + if (!tr) { + if (total) { + if (avancement) { avancement.textContent = textes.lotTermine; } + window.location.reload(); + } + return; + } + var badge = tr.querySelector('.etat-lot'); + var detail = tr.querySelector('.detail-lot'); + badge.textContent = textes.analyseEnCours; + badge.className = 'badge badge-lot-analyse etat-lot'; + var corps = new URLSearchParams(); + corps.set('csrf', csrf); + corps.set('id', tr.dataset.id); + fetch(lot.dataset.url, { method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: corps, credentials: 'same-origin' }) + .then(function (r) { return r.json(); }) + .then(function (j) { + var etat = j.etat || 'erreur'; + tr.dataset.etat = etat; + badge.className = 'badge badge-lot-' + etat + ' etat-lot'; + badge.textContent = etat === 'pret' ? textes.lotPret : (etat === 'erreur' ? textes.lotErreur : textes.lotEnAttente); + if (j.candidat) { tr.querySelector('.candidat-lot').textContent = j.candidat; } + if (j.erreur) { detail.textContent = j.erreur; detail.hidden = false; } + }) + .catch(function () { + badge.className = 'badge badge-lot-erreur etat-lot'; + badge.textContent = textes.lotErreur; + detail.textContent = textes.connexionPerdue; + detail.hidden = false; + }) + .then(analyserSuivant); + }; + analyserSuivant(); + } + + // ------------------------------------------------------------------------- + // Messages : fermeture d'un clic ; une confirmation s'efface seule après 6 secondes + // ------------------------------------------------------------------------- + document.querySelectorAll('main > .flash[role="status"]').forEach(function (flash) { + var partir = function () { + flash.classList.add('part'); + window.setTimeout(function () { flash.remove(); }, 260); + }; + var texte = document.createElement('span'); + while (flash.firstChild) { texte.appendChild(flash.firstChild); } + var fermer = document.createElement('button'); + fermer.type = 'button'; + fermer.className = 'fermer'; + fermer.setAttribute('aria-label', textes.fermer); + fermer.textContent = '×'; + fermer.addEventListener('click', partir); + flash.appendChild(texte); + flash.appendChild(fermer); + flash.classList.add('fermable'); + if (flash.classList.contains('flash-succes')) { window.setTimeout(partir, 6000); } + }); + + // Photo introuvable : les initiales la remplacent + document.querySelectorAll('img.avatar').forEach(function (img) { + var remplacer = function () { + var s = document.createElement('span'); + s.className = img.className + ' avatar-vide'; + s.setAttribute('aria-hidden', 'true'); + s.textContent = img.dataset.initiales || ''; + img.replaceWith(s); + }; + if (img.complete && img.naturalWidth === 0) { remplacer(); } else { img.addEventListener('error', remplacer); } + }); + + // Bouton d'impression (aide) + document.querySelectorAll('[data-imprimer]').forEach(function (b) { + b.addEventListener('click', function () { window.print(); }); + }); + + // ------------------------------------------------------------------------- + // Confirmation avant une action irréversible + // ------------------------------------------------------------------------- + document.querySelectorAll('form[data-confirmer]').forEach(function (form) { + form.addEventListener('submit', function (e) { + if (!window.confirm(form.dataset.confirmer)) { e.preventDefault(); } + }); + }); + + // ------------------------------------------------------------------------- + // Fiche profil : le motif n'apparaît que pour « Non retenu » + // ------------------------------------------------------------------------- + document.querySelectorAll('select[data-motif]').forEach(function (select) { + var champ = document.getElementById(select.dataset.motif); + var saisie = champ.querySelector('input'); + var basculer = function () { + var refus = select.value === 'non_retenu'; + champ.hidden = !refus; + saisie.required = refus; + }; + select.addEventListener('change', basculer); + basculer(); + }); + + // ------------------------------------------------------------------------- + // Fiche profil : onglets Profil, Suivi, Missions, Dossier + // L'adresse garde l'onglet (#suivi) ou une section (#relances) : un retour après + // enregistrement, un lien de la page Suivi ou un rechargement rouvre le bon onglet. + // ------------------------------------------------------------------------- + var barreOnglets = document.querySelector('[data-onglets]'); + if (barreOnglets) { + var onglets = Array.prototype.slice.call(barreOnglets.querySelectorAll('[data-onglet]')); + var panneaux = onglets.map(function (o) { return document.getElementById(o.dataset.onglet); }); + + document.body.classList.add('avec-onglets'); + barreOnglets.setAttribute('role', 'tablist'); + onglets.forEach(function (o, i) { + o.setAttribute('role', 'tab'); + o.setAttribute('aria-controls', panneaux[i].id); + panneaux[i].setAttribute('role', 'tabpanel'); + panneaux[i].setAttribute('tabindex', '-1'); + }); + + var activer = function (n) { + onglets.forEach(function (o, i) { + var choisi = i === n; + o.setAttribute('aria-selected', choisi ? 'true' : 'false'); + o.tabIndex = choisi ? 0 : -1; + panneaux[i].hidden = !choisi; + }); + }; + + // Ancre → onglet à ouvrir et élément à montrer. Une ancre inconnue ne change rien. + var cibleDe = function (ancre) { + var id = decodeURIComponent((ancre || '').replace(/^#/, '')); + if (!id) { return null; } + var el = document.getElementById(id); + var panneau = el && el.closest('.panneau'); + return panneau ? { n: panneaux.indexOf(panneau), el: el, section: el !== panneau } : null; + }; + + // Le panneau commence sous la barre collée : on n'y descend que si la barre est déjà collée + var montrer = function (cible) { + if (cible.section) { + cible.el.scrollIntoView({ block: 'start' }); + var replie = cible.el.closest('details'); + if (replie) { replie.open = true; } + } else if (barreOnglets.getBoundingClientRect().top <= parseFloat(getComputedStyle(barreOnglets).top) + 1) { + cible.el.scrollIntoView({ block: 'start' }); + } + }; + + var aller = function (ancre, defiler) { + var cible = cibleDe(ancre); + if (!cible) { return false; } + activer(cible.n); + if (defiler) { montrer(cible); } + return true; + }; + + if (!aller(window.location.hash, true)) { activer(0); } + + // Clic sur un onglet ou sur un lien interne de la fiche (ligne « À faire ») + document.addEventListener('click', function (e) { + var lien = e.target.closest('a[href^="#"]'); + if (!lien || e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + var ancre = lien.getAttribute('href'); + if (!cibleDe(ancre)) { return; } + e.preventDefault(); + if (window.history.replaceState) { window.history.replaceState(null, '', ancre); } + aller(ancre, true); + }); + + window.addEventListener('hashchange', function () { aller(window.location.hash, true); }); + + // Clavier : flèches, Début et Fin d'un onglet à l'autre ; Espace ouvre l'onglet + barreOnglets.addEventListener('keydown', function (e) { + var i = onglets.indexOf(document.activeElement); + if (i === -1) { return; } + var n = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: onglets.length - 1 }[e.key]; + if (e.key === ' ') { e.preventDefault(); onglets[i].click(); return; } + if (n === undefined) { return; } + e.preventDefault(); + n = (n + onglets.length) % onglets.length; + onglets[n].focus(); + onglets[n].click(); + }); + } + + // ------------------------------------------------------------------------- + // Filtres : une liste déroulante changée relance la recherche, sans bouton à cliquer. + // Sur écran étroit, les filtres des Profils se replient derrière un bouton « Filtres ». + // ------------------------------------------------------------------------- + document.querySelectorAll('form[data-auto]').forEach(function (form) { + form.querySelectorAll('select').forEach(function (select) { + select.addEventListener('change', function () { + if (form.requestSubmit) { form.requestSubmit(); } else { form.submit(); } + }); + }); + form.querySelectorAll('[data-auto-masquer]').forEach(function (b) { b.hidden = true; }); + }); + + document.querySelectorAll('form[data-repli]').forEach(function (form) { + var listes = Array.prototype.slice.call(form.querySelectorAll('select')); + var actifs = listes.filter(function (s) { return s.selectedIndex > 0; }).length; + var bouton = document.createElement('button'); + bouton.type = 'button'; + bouton.className = 'secondaire bouton-filtres'; + bouton.textContent = textes.filtres + (actifs ? ' (' + actifs + ')' : ''); + var replier = function (replie) { + form.classList.toggle('replie', replie); + bouton.setAttribute('aria-expanded', replie ? 'false' : 'true'); + }; + bouton.addEventListener('click', function () { replier(!form.classList.contains('replie')); }); + var recherche = form.querySelector('label.large'); + form.insertBefore(bouton, recherche ? recherche.nextSibling : form.firstChild); + replier(actifs === 0); + }); + + // ------------------------------------------------------------------------- + // Double authentification : le QR code se dessine à partir de l'adresse otpauth:// + // La clé reste écrite en clair à côté, pour une saisie à la main. + // ------------------------------------------------------------------------- + document.querySelectorAll('[data-qr]').forEach(function (zone) { + if (!window.qrcode) { zone.hidden = true; return; } + var qr = window.qrcode(0, 'M'); + qr.addData(zone.dataset.qr); + qr.make(); + zone.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 3, scalable: true }); + }); + + // Copier un lien ou des codes dans le presse-papiers + document.querySelectorAll('[data-copier]').forEach(function (bouton) { + bouton.addEventListener('click', function () { + var texte = bouton.dataset.copier; + var fait = function () { + var avant = bouton.textContent; + bouton.textContent = bouton.dataset.copie || textes.copie; + window.setTimeout(function () { bouton.textContent = avant; }, 2000); + }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(texte).then(fait); + } else { + var zone = document.createElement('textarea'); + zone.value = texte; + document.body.appendChild(zone); + zone.select(); + try { document.execCommand('copy'); fait(); } catch (e) { /* le texte reste sélectionnable à l'écran */ } + zone.remove(); + } + }); + }); + + // ------------------------------------------------------------------------- + // Traduction des profils en arrière-plan : une par requête, tant que la page reste ouverte. + // Une erreur arrête la série ; la page Comptes la montre au gestionnaire. + // ------------------------------------------------------------------------- + var fileTraduction = document.querySelector('meta[name="traductions"]'); + if (fileTraduction && parseInt(fileTraduction.content, 10) > 0) { + var traduireSuivante = function () { + var corps = new URLSearchParams(); + corps.set('csrf', csrf); + fetch(fileTraduction.dataset.url, { method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: corps, credentials: 'same-origin' }) + .then(function (r) { return r.json(); }) + .then(function (j) { + if (j.fait && j.reste > 0) { window.setTimeout(traduireSuivante, 1500); } + }) + .catch(function () { /* nouvel essai au prochain chargement de page */ }); + }; + window.setTimeout(traduireSuivante, 2500); + } + + // ------------------------------------------------------------------------- + // Modifier la fiche : la date n'apparaît que pour « Date précise » ; + // le permis de travail ne concerne que les profils sans nationalité UE, EEE ou suisse + // ------------------------------------------------------------------------- + var choixDispo = document.querySelector('select[name="disponibilite_code"]'); + if (choixDispo) { + var champDate = document.getElementById('champ-disponible-le'); + var montrerDate = function () { + var date = choixDispo.value === 'date'; + champDate.hidden = !date; + champDate.querySelector('input').required = date; + }; + choixDispo.addEventListener('change', montrerDate); + montrerDate(); + } + var nationalites = document.querySelectorAll('select[data-nationalite]'); + var blocPermis = document.getElementById('bloc-permis'); + if (nationalites.length && blocPermis) { + var libres = (blocPermis.dataset.libres || '').split(','); + var nonRequis = document.getElementById('permis-non-requis'); + var majPermis = function () { + var codes = Array.prototype.map.call(nationalites, function (s) { return s.value; }).filter(Boolean); + var libre = codes.some(function (c) { return libres.indexOf(c) !== -1; }); + blocPermis.hidden = libre; + nonRequis.hidden = !libre; + }; + nationalites.forEach(function (s) { s.addEventListener('change', majPermis); }); + majPermis(); + } +})(); diff --git a/public/assets/cv.css b/public/assets/cv.css new file mode 100644 index 0000000..c977111 --- /dev/null +++ b/public/assets/cv.css @@ -0,0 +1,150 @@ +/* CV au format IT Club : A4, noir et or (#ceb73b, couleur du logo). */ + +:root { + --noir: #111111; + --or: #ceb73b; + --or-texte: #7d6c12; /* or assombri : lisible en petit corps sur fond blanc */ + --gris: #555555; + --trait: #d9d6cc; + --marge: 14mm; +} + +/* Marges latérales et haute à zéro : le bandeau noir va jusqu'au bord de la feuille. + Les marges du texte viennent du tableau .corps, répété sur chaque page. */ +@page { + size: A4; + margin: 0 0 10mm; + @bottom-right { + content: counter(page) " / " counter(pages); + font: 7.5pt Arial, Helvetica, sans-serif; + color: #555555; + padding-right: 14mm; + vertical-align: top; + } +} + +* { box-sizing: border-box; } + +html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + +body { + margin: 0; + background: #e9e7e1; + color: var(--noir); + font: 9.3pt/1.36 Arial, "Helvetica Neue", Helvetica, sans-serif; +} + +/* Barre d'outils, hors impression ---------------------------------------- */ +.outils { + display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; + padding: 0.6rem 1.25rem; background: var(--noir); color: #fff; font-size: 0.9rem; +} +.outils a { color: #fff; } +.versions { display: flex; gap: 0.25rem; } +.versions:first-of-type { margin-left: auto; } +.versions:last-of-type { margin-right: auto; } +.versions a { padding: 0.25rem 0.75rem; border: 1px solid #555; border-radius: 999px; text-decoration: none; } +.versions a[aria-current] { background: var(--or); border-color: var(--or); color: var(--noir); font-weight: 700; } +.outils .action { + font: inherit; font-weight: 700; cursor: pointer; padding: 0.4rem 1rem; text-decoration: none; + border: 1px solid var(--or); border-radius: 6px; background: var(--or); color: var(--noir); +} +.outils a:focus-visible, .outils button:focus-visible { outline: 3px solid #fff; outline-offset: 2px; } + +/* Messages au-dessus de la feuille, hors impression */ +.note { max-width: 210mm; margin: 1rem auto 0; padding: 0.75rem 1rem; background: #fff; border-left: 4px solid var(--or); font-size: 0.9rem; } +.note p { margin: 0 0 0.5rem; } +.note-erreur { border-left-color: #a3341f; } +.note-actions { display: flex; flex-wrap: wrap; gap: 0.75rem 1.5rem; align-items: flex-start; } +.note button { font: inherit; font-weight: 700; cursor: pointer; padding: 0.4rem 1rem; border: 1px solid var(--noir); border-radius: 6px; background: var(--noir); color: #fff; } +.note button[disabled] { opacity: 0.6; cursor: progress; } +.note details summary { cursor: pointer; font-weight: 700; padding: 0.4rem 0; } +.note ol { margin: 0.4rem 0 0; padding-left: 1.2rem; display: grid; gap: 0.5rem; } +.note label { display: block; margin-bottom: 0.4rem; } +.note a { color: var(--noir); } + +/* Feuille ---------------------------------------------------------------- */ +.page { + width: 210mm; max-width: 100%; min-height: 297mm; margin: 1.5rem auto; padding: var(--marge); + background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, 0.18); +} + +.bandeau { + display: flex; align-items: center; gap: 7mm; + margin: calc(-1 * var(--marge)) calc(-1 * var(--marge)) 5mm; padding: 6.5mm var(--marge) 6mm; + background: var(--noir); color: #fff; border-bottom: 1mm solid var(--or); +} +.logo { width: 36mm; height: auto; flex: none; } +.identite { border-left: 0.3mm solid var(--or); padding-left: 7mm; min-width: 0; } +h1 { margin: 0; font: 400 19pt/1.1 Georgia, "Times New Roman", serif; letter-spacing: 0.01em; overflow-wrap: anywhere; } +.titre { margin: 1mm 0 0; color: var(--or); font-size: 10.5pt; letter-spacing: 0.03em; } + +.reperes { display: flex; flex-wrap: wrap; gap: 0.8mm 7mm; margin: 0 0 3.6mm; padding: 0 0 2.6mm; border-bottom: 0.2mm solid var(--trait); } +.reperes span { white-space: nowrap; } +.reperes small { font-size: 6.8pt; text-transform: uppercase; letter-spacing: 0.08em; color: var(--gris); margin-right: 0.6mm; } + +section { margin: 0 0 3.6mm; } +h2 { + margin: 0 0 1.6mm; padding: 0 0 0.7mm; border-bottom: 0.4mm solid var(--or); + font: 400 11pt/1.2 Georgia, "Times New Roman", serif; letter-spacing: 0.02em; + break-after: avoid; +} +h3 { margin: 0 0 0.6mm; font-size: 9.8pt; } +p { margin: 0 0 0.6mm; } + +.competences { width: 100%; border-collapse: collapse; } +.competences th { width: 31mm; text-align: left; vertical-align: top; padding: 0.35mm 3mm 0.35mm 0; font-size: 7.3pt; text-transform: uppercase; letter-spacing: 0.06em; color: var(--gris); font-weight: 400; line-height: 1.7; } +.competences td { padding: 0.35mm 0; } +.techno { white-space: nowrap; } +.techno small { color: var(--or-texte); font-size: 7.5pt; } + +.experience { display: grid; grid-template-columns: 17mm 1fr; gap: 0 4mm; padding: 1.7mm 0; border-top: 0.2mm solid var(--trait); break-inside: avoid; } +.experience:first-of-type { border-top: 0; padding-top: 0; } +.periode .fleche { display: none; } +.periode { margin: 0.3mm 0 0; font-size: 8pt; color: var(--or-texte); font-weight: 700; line-height: 1.3; } +.employeur { color: var(--gris); font-weight: 400; font-size: 9pt; margin-left: 1.5mm; } +.environnement { font-size: 8.3pt; color: #333; margin: 0.4mm 0 0; } +.cadre { font-size: 8.5pt; color: var(--gris); margin: 0.3mm 0 0.6mm; } +.mission { font-style: italic; margin: 0 0 0.8mm; } + +.deux { display: grid; grid-template-columns: 1fr 1fr; gap: 0 7mm; break-inside: avoid; } +.deux ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6mm; } + +.contact { + display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 1mm 6mm; + margin-top: 4.5mm; padding: 2.4mm 4mm; background: #f6f3e4; border-left: 1mm solid var(--or); break-inside: avoid; +} +.signature { font: italic 10pt Georgia, "Times New Roman", serif; } + +.mentions { margin: 4mm 0 0; font-size: 6.8pt; color: var(--gris); text-align: center; letter-spacing: 0.02em; } + +.corps { width: 100%; border-collapse: collapse; } +.corps > * > tr > td { padding: 0; } +.marge-haut, .marge-bas { height: 0; } + +/* Aperçu sur petit écran : la feuille occupe la largeur disponible */ +@media screen and (max-width: 820px) { + :root { --marge: 5vw; } + .page { margin: 0 auto; min-height: 0; box-shadow: none; } + .bandeau { flex-wrap: wrap; gap: 4mm; } + .identite { border-left: 0; padding-left: 0; } + .experience { grid-template-columns: 1fr; } + .periode br { display: none; } + .periode .fleche { display: inline; } + .periode { margin-bottom: 0.5mm; } + .deux { grid-template-columns: 1fr; gap: 3mm; } + .versions { margin: 0; } + .outils .action { flex: 1 1 auto; text-align: center; } +} + +/* Impression ------------------------------------------------------------- */ +@media print { + body { background: #fff; } + .outils, .note { display: none; } + .page { width: auto; min-height: 0; margin: 0; padding: 0 var(--marge); box-shadow: none; } + .bandeau { margin: 0 calc(-1 * var(--marge)); } + .marge-haut { height: 7mm; } + .marge-bas { height: 7mm; } + .mentions { position: fixed; left: 0; right: 0; bottom: 0; margin: 0; } + a { color: inherit; text-decoration: none; } +} diff --git a/public/assets/cv.js b/public/assets/cv.js new file mode 100644 index 0000000..e2f2ac7 --- /dev/null +++ b/public/assets/cv.js @@ -0,0 +1,12 @@ +document.getElementById('imprimer').addEventListener('click', function () { + window.print(); +}); + +// Envoi long (traduction) : le bouton annonce l'attente et empêche un second envoi +document.querySelectorAll('form[data-attente]').forEach(function (form) { + form.addEventListener('submit', function () { + var bouton = form.querySelector('button[type="submit"]'); + bouton.disabled = true; + bouton.textContent = form.dataset.attente; + }); +}); diff --git a/public/assets/europass.css b/public/assets/europass.css new file mode 100644 index 0000000..28da7e0 --- /dev/null +++ b/public/assets/europass.css @@ -0,0 +1,153 @@ +/* CV au format Europass : A4, bandeau bleu, police Open Sans (licence SIL OFL, hébergée ici). */ + +@font-face { + font-family: "Open Sans"; font-style: normal; font-weight: 300 800; font-display: swap; + src: url("polices/open-sans-latin-wght-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Open Sans"; font-style: normal; font-weight: 300 800; font-display: swap; + src: url("polices/open-sans-latin-ext-wght-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Open Sans"; font-style: italic; font-weight: 300 800; font-display: swap; + src: url("polices/open-sans-latin-wght-italic.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Open Sans"; font-style: italic; font-weight: 300 800; font-display: swap; + src: url("polices/open-sans-latin-ext-wght-italic.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +:root { + --bleu: #004b80; /* bandeau */ + --bleu-clair: #4d9acb; /* filets et puces des titres */ + --filet: #c0dbed; + --fond-ligne: #f2f8fc; + --texte: #1a1a1a; + --gris: #404040; + --gris-clair: #6f6f6f; + --marge: 14mm; +} + +/* Le bandeau va jusqu'au bord de la feuille ; les marges du texte viennent du tableau .corps, répété à chaque page */ +@page { + size: A4; + margin: 0 0 10mm; + @bottom-right { + content: counter(page) " / " counter(pages); + font: 7.5pt "Open Sans", Arial, sans-serif; + color: #6f6f6f; + padding-right: 14mm; + vertical-align: top; + } +} + +* { box-sizing: border-box; } +html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + +body { + margin: 0; + background: #e6ebef; + color: var(--texte); + font: 9.4pt/1.42 "Open Sans", Arial, Helvetica, sans-serif; +} + +/* Barre d'outils, hors impression ---------------------------------------- */ +.outils { + display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; + padding: 0.6rem 1.25rem; background: #0b1f30; color: #fff; font-size: 0.9rem; +} +.outils a { color: #fff; } +.versions { display: flex; gap: 0.25rem; } +.versions:first-of-type { margin-left: auto; } +.versions:last-of-type { margin-right: auto; } +.versions a { padding: 0.25rem 0.75rem; border: 1px solid #4a6378; border-radius: 999px; text-decoration: none; } +.versions a[aria-current], .versions a[aria-pressed="true"] { background: var(--bleu-clair); border-color: var(--bleu-clair); color: #fff; font-weight: 700; } +.versions a[aria-pressed="false"] { color: #b9c9d6; text-decoration: line-through; text-decoration-thickness: 1px; } +.outils .action { + font: inherit; font-weight: 700; cursor: pointer; padding: 0.4rem 1rem; text-decoration: none; + border: 1px solid #fff; border-radius: 6px; background: #fff; color: var(--bleu); +} +.outils a:focus-visible, .outils button:focus-visible { outline: 3px solid #ffd54a; outline-offset: 2px; } + +.note { max-width: 210mm; margin: 1rem auto 0; padding: 0.75rem 1rem; background: #fff; border-left: 4px solid var(--bleu-clair); font-size: 0.9rem; } +.note p { margin: 0 0 0.5rem; } +.note-erreur { border-left-color: #a3341f; } +.note button { font: inherit; font-weight: 700; cursor: pointer; padding: 0.4rem 1rem; border: 1px solid var(--bleu); border-radius: 6px; background: var(--bleu); color: #fff; } +.note button[disabled] { opacity: 0.6; cursor: progress; } + +/* Feuille A4 ------------------------------------------------------------- */ +.page { width: 210mm; min-height: 297mm; margin: 1.25rem auto 2rem; background: #fff; box-shadow: 0 4px 22px rgba(0, 0, 0, 0.14); } + +.bandeau { + position: relative; display: flex; gap: 6mm; align-items: center; + background: var(--bleu); color: #fff; padding: 9mm var(--marge) 8mm; +} +.bandeau .identite { flex: 1; min-width: 0; padding-right: 42mm; } +.bandeau h1 { margin: 0; font-size: 17pt; font-weight: 700; line-height: 1.15; } +.bandeau .titre { margin: 1mm 0 0; font-size: 10.5pt; font-weight: 400; color: #dbe9f4; } +.bandeau .coordonnees { margin: 3.5mm 0 0; display: flex; flex-wrap: wrap; gap: 1mm 0; font-size: 8.8pt; } +.bandeau .coordonnees span { padding-right: 2.5mm; margin-right: 2.5mm; border-right: 0.3mm solid #6e95b8; } +.bandeau .coordonnees span:last-child { border-right: 0; } +.bandeau .coordonnees b { font-weight: 700; } +.bandeau a { color: #fff; } +.bandeau .logo { position: absolute; top: 7mm; right: var(--marge); width: 34mm; height: auto; } +.portrait { width: 30mm; height: 30mm; flex: none; object-fit: cover; object-position: center 20%; border-radius: 50%; border: 0.8mm solid #fff; } + +table.corps { width: 100%; border-collapse: collapse; } +table.corps > thead td, table.corps > tfoot td, table.corps > tbody > tr > td { padding: 0 var(--marge); } +.marge-haut { height: 5mm; } +.marge-bas { height: 4mm; } + +section { margin: 0 0 4mm; } +h2 { + position: relative; margin: 2mm 0 2.5mm; padding: 0 0 1mm; font-size: 11pt; font-weight: 800; text-transform: uppercase; letter-spacing: 0.01em; + border-bottom: 0.6mm solid var(--bleu-clair); break-after: avoid; +} +h2::before { content: ""; position: absolute; left: -5.5mm; top: 1.4mm; width: 2.4mm; height: 2.4mm; border-radius: 50%; background: var(--bleu-clair); } +h3 { margin: 0 0 1mm; font-size: 10pt; font-weight: 700; color: var(--gris); text-transform: none; } +h4 { margin: 2.5mm 0 1mm; font-size: 9.5pt; font-weight: 700; color: var(--gris-clair); break-after: avoid; } +p { margin: 0 0 1mm; } +ul { margin: 0.5mm 0 1mm; padding-left: 7mm; } +li { margin: 0 0 0.6mm; } + +.element { padding: 0 0 2.5mm; margin: 0 0 2.5mm; border-bottom: 0.3mm solid var(--filet); break-inside: avoid-page; } +.element:last-child { border-bottom: 0; margin-bottom: 0; } +.date { color: var(--gris-clair); font-size: 8.8pt; margin: 0 0 0.6mm; } +.maison { font-weight: 400; color: var(--gris); } +.employeur { margin: 0 0 0.4mm; font-size: 9.6pt; } +.employeur b { font-weight: 700; } +.dates { font-weight: 400; color: var(--bleu); } +.sous-titre { margin-top: 1.2mm; } + +table.grille { width: 100%; border-collapse: collapse; margin: 1mm 0 1mm; font-size: 8.8pt; break-inside: avoid; } +table.grille th, table.grille td { padding: 1.5mm 1.5mm; text-align: center; vertical-align: middle; } +table.grille thead th { font-weight: 700; } +table.grille thead tr:first-child { border-bottom: 0.3mm solid var(--bleu-clair); } +table.grille tr.sous th { font-weight: 400; font-size: 8.3pt; line-height: 1.25; } +table.grille tbody tr { border-top: 0.3mm solid var(--bleu-clair); } +table.grille tbody tr:last-child { border-bottom: 0.3mm solid var(--bleu-clair); } +table.grille tbody tr:nth-child(odd) { background: var(--fond-ligne); } +table.grille tbody th { text-align: left; font-weight: 800; width: 18%; } +.legende { font-style: italic; color: var(--gris-clair); font-size: 8pt; } +.langues-simples { list-style: none; padding: 0; margin: 0 0 1mm; display: flex; flex-wrap: wrap; gap: 1mm 6mm; } + +.liste { line-height: 1.7; } +.liste .sep { color: #aab4bc; padding: 0 0.6mm; } +.benevolat { font-weight: 700; } + +@media screen and (max-width: 840px) { + .page { width: auto; min-height: 0; margin: 0.75rem; } + .bandeau { flex-wrap: wrap; } + .bandeau .identite { padding-right: 0; } + .bandeau .logo { position: static; order: -1; width: 30mm; } +} + +@media print { + body { background: #fff; } + .outils, .note { display: none !important; } + .page { width: auto; min-height: 0; margin: 0; box-shadow: none; } +} diff --git a/public/assets/europass/logo-europass-blanc.png b/public/assets/europass/logo-europass-blanc.png new file mode 100644 index 0000000..7183eb8 Binary files /dev/null and b/public/assets/europass/logo-europass-blanc.png differ diff --git a/public/assets/logo-accueil-2x.png b/public/assets/logo-accueil-2x.png new file mode 100644 index 0000000..e497448 Binary files /dev/null and b/public/assets/logo-accueil-2x.png differ diff --git a/public/assets/logo-accueil-3x.png b/public/assets/logo-accueil-3x.png new file mode 100644 index 0000000..ed89923 Binary files /dev/null and b/public/assets/logo-accueil-3x.png differ diff --git a/public/assets/logo-accueil.png b/public/assets/logo-accueil.png new file mode 100644 index 0000000..99672fc Binary files /dev/null and b/public/assets/logo-accueil.png differ diff --git a/public/assets/logo-entete-2x.png b/public/assets/logo-entete-2x.png new file mode 100644 index 0000000..635a6ee Binary files /dev/null and b/public/assets/logo-entete-2x.png differ diff --git a/public/assets/logo-entete-3x.png b/public/assets/logo-entete-3x.png new file mode 100644 index 0000000..d976704 Binary files /dev/null and b/public/assets/logo-entete-3x.png differ diff --git a/public/assets/logo-entete.png b/public/assets/logo-entete.png new file mode 100644 index 0000000..33603de Binary files /dev/null and b/public/assets/logo-entete.png differ diff --git a/public/assets/logo-itclub-noir.png b/public/assets/logo-itclub-noir.png new file mode 100644 index 0000000..0d931ad Binary files /dev/null and b/public/assets/logo-itclub-noir.png differ diff --git a/public/assets/logo-itclub-or.png b/public/assets/logo-itclub-or.png new file mode 100644 index 0000000..18d5e6b Binary files /dev/null and b/public/assets/logo-itclub-or.png differ diff --git a/public/assets/polices/LICENCE-bodoni-moda.txt b/public/assets/polices/LICENCE-bodoni-moda.txt new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/polices/LICENCE-instrument-sans.txt b/public/assets/polices/LICENCE-instrument-sans.txt new file mode 100644 index 0000000..9a39a8e --- /dev/null +++ b/public/assets/polices/LICENCE-instrument-sans.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/assets/polices/LICENCE-open-sans.txt b/public/assets/polices/LICENCE-open-sans.txt new file mode 100644 index 0000000..33e322e --- /dev/null +++ b/public/assets/polices/LICENCE-open-sans.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) OpenSans-Italic[wdth,wght].ttf: Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/assets/polices/bodoni-moda-latin-wght-italic.woff2 b/public/assets/polices/bodoni-moda-latin-wght-italic.woff2 new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/polices/bodoni-moda-latin-wght-normal.woff2 b/public/assets/polices/bodoni-moda-latin-wght-normal.woff2 new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/polices/instrument-sans-latin-wght-normal.woff2 b/public/assets/polices/instrument-sans-latin-wght-normal.woff2 new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/polices/open-sans-latin-ext-wght-italic.woff2 b/public/assets/polices/open-sans-latin-ext-wght-italic.woff2 new file mode 100644 index 0000000..bc2aea0 Binary files /dev/null and b/public/assets/polices/open-sans-latin-ext-wght-italic.woff2 differ diff --git a/public/assets/polices/open-sans-latin-ext-wght-normal.woff2 b/public/assets/polices/open-sans-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..08bed85 Binary files /dev/null and b/public/assets/polices/open-sans-latin-ext-wght-normal.woff2 differ diff --git a/public/assets/polices/open-sans-latin-wght-italic.woff2 b/public/assets/polices/open-sans-latin-wght-italic.woff2 new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/polices/open-sans-latin-wght-normal.woff2 b/public/assets/polices/open-sans-latin-wght-normal.woff2 new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/qrcode.js b/public/assets/qrcode.js new file mode 100644 index 0000000..76889b5 --- /dev/null +++ b/public/assets/qrcode.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/public/assets/sortable.min.js b/public/assets/sortable.min.js new file mode 100644 index 0000000..bb99533 --- /dev/null +++ b/public/assets/sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.2 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode))}var i;return null}var g,m=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(m," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(m," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function b(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Bt(t){V&&V.parentNode[K]._isOutsideThisEl(t.target)}function Ft(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Pt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Ft.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in W.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in kt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Nt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),Dt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,x())}function jt(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Ht(t){t.draggable=!1}function Lt(){Tt=!1}function Kt(t){return setTimeout(t,0)}function Wt(t){return clearTimeout(t)}Ft.prototype={constructor:Ft,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(mt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,V):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){xt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&xt.push(o)}}(o),!V&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||tt===l)){if(ot=j(l),rt=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return q({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),G("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return q({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),G("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!V&&n.parentNode===r&&(o=X(n),Q=r,Z=(V=n).parentNode,J=V.nextSibling,tt=n,lt=a.group,ct={target:Ft.dragged=V,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ct.clientX-o.left,pt=ct.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,V.style["will-change"]="all",o=function(){G("delayEnded",i,{evt:t}),Ft.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(V.draggable=!0),i._triggerDragStart(t,e),q({sortable:i,name:"choose",originalEvent:t}),k(V,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){b(V,t.trim(),Ht)}),h(l,"dragover",Yt),h(l,"mousemove",Yt),h(l,"touchmove",Yt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,V.draggable=!0),G("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Ft.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){V&&Ht(V),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(V,"dragend",this),h(Q,"dragstart",this._onDragStart));try{document.selection?Kt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;wt=!1,Q&&V?(G("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Bt),n=this.options,t||k(V,n.dragClass,!1),k(V,n.ghostClass,!0),Ft.active=this,t&&this._appendGhost(),q({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ut){this._lastX=ut.clientX,this._lastY=ut.clientY,Rt();for(var t=document.elementFromPoint(ut.clientX,ut.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ut.clientX,ut.clientY))!==e;)e=t;if(V.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:ut.clientX,clientY:ut.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=(t=e).parentNode);Xt()}},_onTouchMove:function(t){if(ct){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=$&&v($,!0),a=$&&r&&r.a,l=$&&r&&r.d,e=Mt&&yt&&E(yt),a=(i.clientX-ct.clientX+o.x)/(a||1)+(e?e[0]-Ct[0]:0)/(a||1),l=(i.clientY-ct.clientY+o.y)/(l||1)+(e?e[1]-Ct[1]:0)/(l||1);if(!Ft.active&&!wt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))D.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>D.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,$),e?t.clientX<_.left-10||t.clientY h1 { margin-bottom: 1.25rem; } +h1 small { font: 400 0.9375rem var(--texte); color: var(--gris); margin-left: 0.6rem; letter-spacing: 0; } +h2 { font: 600 1rem/1.3 var(--texte); margin: 0 0 0.8rem; } +h3 { font: 600 1rem/1.3 var(--texte); margin: 0; } +p { margin: 0 0 0.6rem; } +em { color: var(--gris); } +code { font-size: 0.9em; background: var(--fond); padding: 0.05em 0.3em; border-radius: 3px; } + +:focus-visible { outline: 3px solid var(--petrole); outline-offset: 2px; } +.entete :focus-visible, .porte :focus-visible { outline-color: var(--or); } + +.evitement { position: absolute; left: -999rem; top: 0; background: var(--or); color: var(--noir); padding: 0.6rem 1rem; z-index: 10; font-weight: 600; } +.evitement:focus { left: 0; } +[hidden], .cachee { display: none !important; } + +/* En-tête ---------------------------------------------------------------- + Une seule ligne, fixe en haut de page : la marque, cinq rubriques, le sous-menu Gestion, + puis la recherche, le bouton Importer et le menu du compte. */ +.entete { position: sticky; top: 0; z-index: 30; background: var(--noir); color: #fff; padding: 0 var(--gouttiere); display: flex; align-items: center; gap: 1.75rem; min-height: 3.25rem; view-transition-name: entete; } +.entete-barre { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.marque { display: flex; align-items: center; gap: 0.65rem; color: #fff; text-decoration: none; } +.marque img { height: 24px; width: auto; display: block; } +.marque span { font: italic 500 1.3rem/1 var(--titre); padding-left: 0.65rem; border-left: 1px solid #4a4a4a; transition: color 0.12s; } +.marque:hover { color: #fff; } +.marque:hover span { color: var(--or); } +.menu-bouton { display: none; } + +.entete-menu { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; } +.entete nav { display: flex; align-items: stretch; gap: 0 1.35rem; flex: 1; font-size: 0.9375rem; } +.entete nav > a, .menu-plus > summary { + display: flex; align-items: center; gap: 0.4rem; min-height: 3.25rem; color: #cfcfcf; text-decoration: none; white-space: nowrap; + box-shadow: inset 0 -3px 0 transparent; transition: color 0.12s, box-shadow 0.12s; +} +.entete nav > a:hover, .menu-plus > summary:hover, .menu-plus[open] > summary { color: #fff; } +.entete nav > a[aria-current], .menu-plus.actif > summary { color: #fff; box-shadow: inset 0 -3px 0 var(--or); } +.pastille { background: var(--or); color: var(--noir); border-radius: 999px; padding: 0 0.42rem; font-size: 0.75rem; font-weight: 700; line-height: 1.5; } + +/* Sous-menus : Gestion et compte */ +.menu-plus { position: relative; margin: 0; } +.menu-plus > summary { list-style: none; cursor: pointer; font-weight: 400; padding: 0; margin: 0; } +.menu-plus > summary::-webkit-details-marker { display: none; } +.menu-plus > summary::after { content: ""; width: 0.36rem; height: 0.36rem; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-0.12rem) rotate(45deg); transition: transform 0.15s; } +.menu-plus[open] > summary::after { transform: translateY(0.08rem) rotate(225deg); } +.menu-plus-liste { + position: absolute; left: -1rem; top: 100%; z-index: 40; min-width: 13.5rem; display: grid; padding: 0.35rem; + background: #141414; border: 1px solid #333; border-top: 0; border-radius: 0 0 8px 8px; box-shadow: 0 14px 28px rgba(0, 0, 0, 0.35); + animation: derouler 0.14s ease-out; +} +.menu-plus-liste a, .menu-plus-liste .lien { + display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; width: 100%; min-height: 2.4rem; padding: 0.4rem 0.7rem; + color: #dedede; text-decoration: none; text-align: left; border-radius: 4px; font-size: 0.9375rem; +} +.menu-plus-liste a:hover, .menu-plus-liste .lien:hover { background: #262626; color: #fff; } +.menu-plus-liste a[aria-current] { color: #fff; box-shadow: inset 3px 0 0 var(--or); } +@keyframes derouler { from { opacity: 0; transform: translateY(-4px); } } + +/* Recherche, import, compte */ +.recherche-rapide { display: flex; position: relative; } +.recherche-rapide input[type="search"] { + width: 13rem; min-height: 2.15rem; padding: 0.3rem 2.3rem 0.3rem 0.75rem; font: inherit; font-size: 0.9375rem; color: #fff; + background: #1c1c1c; border: 1px solid #4a4a4a; border-radius: 999px; transition: width 0.18s ease, border-color 0.12s, background 0.12s; +} +.recherche-rapide input[type="search"]::placeholder { color: #9c9c9c; } +.recherche-rapide input[type="search"]:hover { border-color: #777; } +.recherche-rapide input[type="search"]:focus { width: 19rem; background: #000; border-color: var(--or); outline: 0; } +.recherche-rapide button { position: absolute; right: 0.15rem; top: 50%; transform: translateY(-50%); min-height: 0; width: 1.9rem; height: 1.9rem; padding: 0; background: none; border: 0; border-radius: 50%; color: #cfcfcf; } +.recherche-rapide button:hover { background: #2a2a2a; color: var(--or); } +.recherche-rapide svg { width: 1rem; height: 1rem; display: block; } +.bouton.bouton-entete { min-height: 2.15rem; padding: 0.3rem 0.95rem; font-size: 0.9375rem; white-space: nowrap; } +.bouton.bouton-entete[aria-current] { background: #fff; border-color: #fff; } + +.menu-compte > summary { gap: 0.5rem; } +.menu-compte .initiales { display: grid; place-items: center; width: 1.9rem; height: 1.9rem; border-radius: 50%; border: 1px solid #6a6a6a; font-size: 0.75rem; font-weight: 600; color: #fff; letter-spacing: 0.02em; transition: border-color 0.12s; } +.menu-compte > summary:hover .initiales, .menu-compte[open] .initiales, .menu-compte.actif .initiales { border-color: var(--or); } +.menu-compte.actif > summary { box-shadow: none; } +.menu-compte .menu-plus-liste { left: auto; right: -0.5rem; min-width: 15rem; } +.compte-qui { margin: 0; padding: 0.55rem 0.7rem 0.6rem; border-bottom: 1px solid #2c2c2c; color: #fff; font-weight: 600; line-height: 1.3; } +.compte-qui small { display: block; color: #9c9c9c; font-weight: 400; font-size: 0.8125rem; } +.compte-langue { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; padding: 0.45rem 0.7rem; color: #dedede; font-size: 0.9375rem; } +.menu-plus-liste .sortie form { display: contents; } +.menu-plus[open] > summary { margin-bottom: 0; } +.menu-plus-liste .lien { background: none; border: 0; font-weight: 400; cursor: pointer; } +.menu-plus-liste .sortie { border-top: 1px solid #2c2c2c; margin-top: 0.25rem; padding-top: 0.25rem; } +.menu-plus-liste .sortie .lien { text-decoration: none; } + +main { padding: 1.5rem var(--gouttiere) 3rem; max-width: 100rem; margin: 0 auto; } + +/* Page de connexion ------------------------------------------------------ */ +body.accueil { background: var(--noir); color: #fff; min-height: 100vh; display: grid; place-items: center; } +body.accueil main { width: 100%; max-width: 24rem; padding: 2rem 1.25rem; } +.porte { text-align: center; } +.porte img { width: 14rem; max-width: 70%; height: auto; } +.porte-nom { font: italic 500 3.25rem/1 var(--titre); margin: 1.4rem 0 0.4rem; color: #fff; } +.porte-role { color: #bdbdbd; margin-bottom: 2rem; } +.porte form { text-align: left; } +.porte label { color: #e6e6e6; } +.porte input[type="text"], .porte input[type="password"] { background: #151515; border-color: #555; color: #fff; } +.porte button { margin-top: 0.4rem; } +.porte .flash { text-align: left; } + +/* Blocs ------------------------------------------------------------------ */ +.carte { background: var(--surface); border: 1px solid var(--trait); border-radius: 6px; padding: 1.15rem 1.3rem; margin: 0 0 1rem; } +.carte.etroit { max-width: 28rem; margin-left: auto; margin-right: auto; } +.carte.etroit.gauche { margin-left: 0; } +.carte.moyen { max-width: 56rem; margin-left: auto; margin-right: auto; } +.carte.attention { border-left: 4px solid var(--or); } +.carte.mise-en-avant { border-top: 4px solid var(--or); } +.carte > :last-child { margin-bottom: 0; } +.carte ul { margin: 0; padding-left: 1.1rem; } +.carte h1 + .aide { margin-top: 0.5rem; } +.carte > h1 { margin-bottom: 1rem; } + +.barre-titre { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem 2rem; flex-wrap: wrap; margin: 0 0 1.25rem; } +.fil { margin: 0 0 0.9rem; font-size: 0.875rem; color: var(--gris); } +.aide, .vide { color: var(--gris); font-size: 0.875rem; max-width: 70ch; } +.centre { text-align: center; } +.retard { color: var(--brique); font-weight: 600; } + +.deux-colonnes { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(19rem, 1fr); gap: 1rem; align-items: start; } +.deux-colonnes.inverse { grid-template-columns: minmax(18rem, 24rem) minmax(0, 1fr); } +.deux-egales { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr)); gap: 1rem; align-items: start; } +.deux-colonnes > *, .deux-egales > * { min-width: 0; } +.deux-egales > .carte { margin: 0; } + +/* Formulaires ------------------------------------------------------------ */ +.pile { display: grid; gap: 0.9rem; } +label { display: grid; gap: 0.3rem; font-size: 0.875rem; font-weight: 600; } +input, select, textarea, button { font: inherit; color: inherit; } +input[type="text"], input[type="email"], input[type="password"], input[type="search"], select, textarea { + width: 100%; min-height: 2.6rem; padding: 0.5rem 0.7rem; font-weight: 400; + background: #fff; border: 1px solid #98a1a8; border-radius: 4px; +} +textarea { min-height: 5rem; resize: vertical; } +input[type="file"] { font-weight: 400; max-width: 100%; } +input[type="file"]::file-selector-button { + font: inherit; font-weight: 600; margin-right: 0.7rem; padding: 0.45rem 0.8rem; cursor: pointer; + background: #fff; border: 1px solid var(--encre); border-radius: 4px; +} + +button, .bouton { + display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; min-height: 2.6rem; + padding: 0.5rem 1.1rem; cursor: pointer; text-decoration: none; text-align: center; font-weight: 600; line-height: 1.2; + background: var(--or); color: var(--noir); border: 1px solid var(--or); border-radius: 4px; +} +button:hover, .bouton:hover { background: #e0cb55; border-color: #e0cb55; color: var(--noir); } +button.secondaire, .bouton.secondaire { background: #fff; border-color: var(--encre); color: var(--encre); } +button.secondaire:hover, .bouton.secondaire:hover { background: var(--encre); color: #fff; } +button.dangereux { background: #fff; border-color: var(--brique); color: var(--brique); } +button.dangereux:hover { background: var(--brique); color: #fff; } +button.lien { min-height: 0; padding: 0; background: none; border: 0; border-radius: 0; color: var(--petrole); font-weight: 400; text-decoration: underline; text-underline-offset: 0.15em; } +button.lien:hover { background: none; color: var(--noir); } +button[disabled] { opacity: 0.6; cursor: progress; } +.actions { display: flex; gap: 0.6rem; justify-content: flex-end; flex-wrap: wrap; margin-top: 1rem; } +.actions-ligne { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; align-items: center; font-size: 0.875rem; } +td.actions-ligne { justify-content: flex-end; } +.actions-ligne form { display: inline; } + +.filtres { display: flex; flex-wrap: wrap; gap: 0.7rem; align-items: flex-end; margin: 0 0 1rem; } +.filtres label { min-width: 10rem; flex: 0 1 12rem; } +.filtres label.large { flex: 1 1 18rem; } +.filtres .fin { margin-left: auto; } +.barre-titre .filtres { margin: 0; flex: 1 1 28rem; justify-content: flex-end; } + +.champs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.9rem; } +.champs .large { grid-column: 1 / -1; } + +details { margin: 0 0 0.8rem; } +summary { cursor: pointer; color: var(--petrole); font-weight: 600; padding: 0.3rem 0; } +details[open] summary { margin-bottom: 0.7rem; } +.danger { border-top: 1px solid var(--trait); padding-top: 0.9rem; } + +/* Messages --------------------------------------------------------------- */ +.flash { padding: 0.7rem 0.95rem; margin: 0 0 1rem; border-radius: 4px; border-left: 4px solid var(--petrole); background: #e4eef1; color: var(--encre); } +.flash-succes { border-left-color: #2e7d4f; background: #e5f2ea; } +.flash-erreur { border-left-color: var(--brique); background: var(--brique-pale); } +.flash-info { border-left-color: var(--or); background: var(--or-pale); } +.flash ul { margin: 0.4rem 0 0; padding-left: 1.1rem; } + +/* Tableaux et listes ----------------------------------------------------- */ +.defile { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { text-align: left; font-weight: 600; font-size: 0.8125rem; color: var(--gris); } +th, td { padding: 0.5rem 0.9rem 0.5rem 0; border-bottom: 1px solid var(--trait); vertical-align: top; } +tbody tr:last-child > * { border-bottom: 0; } +.nombre { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; padding-right: 0; } +td small, li small { color: var(--gris); font-size: 0.8125rem; } + +.grille { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: 0.4rem 1rem; margin: 0 0 1rem; align-items: baseline; } +.grille.serre { grid-template-columns: 6.5rem minmax(0, 1fr); } +.grille dt { color: var(--gris); font-size: 0.875rem; } +.grille dd { margin: 0; overflow-wrap: anywhere; } +ul.simple { list-style: none; padding: 0 !important; margin: 0 0 0.9rem; display: grid; gap: 0.6rem; } +ul.simple small { display: block; } + +/* Repères : séniorité, statut, échelle d'avancement ---------------------- */ +.badge { display: inline-block; padding: 0.05rem 0.5rem; border: 1px solid #98a1a8; border-radius: 3px; font-size: 0.75rem; font-weight: 600; color: var(--encre); background: #fff; white-space: nowrap; } +.badge-expert { background: var(--noir); border-color: var(--noir); color: var(--or); } +.badge-confirme { background: var(--or-pale); border-color: #cbbb62; color: #4d4204; } +.badge-junior { color: var(--gris); } +.etat { display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.875rem; white-space: nowrap; } + +.echelle { display: inline-grid; gap: 2px; flex: none; vertical-align: middle; } +.echelle i { display: block; width: 14px; height: 2px; background: #c4cace; } +.echelle i.plein { background: var(--noir); } +.echelle-refus i:last-child { background: var(--brique); } +.echelle-grande { gap: 4px; } +.echelle-grande i { width: 34px; height: 5px; } +.echelle-grande i.plein { background: var(--or); box-shadow: 0 0 0 1px #b8a22f inset; } + +.technos { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.5rem 0 0; } +.technos span { background: var(--fond); border-radius: 3px; padding: 0.05rem 0.45rem; font-size: 0.8125rem; } +.technos span.trouve { background: var(--or); color: var(--noir); font-weight: 600; } +.technos-ligne { margin: 0.35rem 0 0; font-size: 0.8125rem; color: var(--gris); } + +/* Kanban ----------------------------------------------------------------- */ +.kanban { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(10.75rem, 1fr); gap: 0.9rem; overflow-x: auto; padding: 0.25rem 0 1.5rem; align-items: start; scroll-snap-type: x proximity; } +.colonne { min-width: 0; scroll-snap-align: start; } +.colonne h2 { display: flex; align-items: center; gap: 0.6rem; margin: 0 0 0.7rem; padding-bottom: 0.6rem; border-bottom: 2px solid var(--noir); font-size: 0.9375rem; line-height: 1.2; min-height: 3.1rem; } +.colonne-non_retenu h2 { border-bottom-color: var(--brique); } +.nom-colonne { flex: 1; } +.compteur { font: 500 1.25rem/1 var(--titre); color: var(--gris); } +.cartes { list-style: none; margin: 0; padding: 0; min-height: 5rem; display: grid; gap: 0.5rem; align-content: start; } +.fiche { background: var(--surface); border: 1px solid var(--trait); border-radius: 4px; padding: 0.65rem 0.75rem; } +.kanban[data-modifiable="1"] .fiche { cursor: grab; } +.kanban[data-modifiable="1"] .fiche:hover { border-color: var(--encre); } +.fiche > a { font-weight: 600; color: var(--encre); text-decoration: none; } +.fiche > a:hover { text-decoration: underline; } +.fiche .poste { margin: 0.1rem 0 0.45rem; color: var(--gris); font-size: 0.875rem; line-height: 1.35; } +.fiche .meta { margin: 0; font-size: 0.8125rem; display: flex; gap: 0.5rem; align-items: center; } +.fiche .motif { margin: 0.5rem 0 0; padding-top: 0.4rem; border-top: 1px solid var(--trait); font-size: 0.8125rem; color: var(--brique); } +.fantome { opacity: 0.35; } + +/* Fiche profil ----------------------------------------------------------- */ +.entete-profil { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem 2rem; flex-wrap: wrap; margin-bottom: 1rem; } +.identite-profil { display: flex; align-items: center; gap: 1.1rem; min-width: 0; } +.sous-titre { margin: 0.45rem 0 0; color: var(--gris); display: flex; gap: 0.3rem 0.9rem; align-items: center; flex-wrap: wrap; } +.exports { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.barre-statut { margin-bottom: 1rem; padding: 0.8rem 1rem; background: var(--surface); border: 1px solid var(--trait); border-radius: 6px; } +.statut-form { display: flex; gap: 0.7rem; align-items: flex-end; flex-wrap: wrap; } +.statut-form label { flex: 0 1 16rem; } +.statut-form label + label { flex: 1 1 16rem; } + +.experience { padding: 0.9rem 0; border-top: 1px solid var(--trait); } +.experience:first-of-type { border-top: 0; padding-top: 0; } +.experience:last-child { padding-bottom: 0; } +.experience p { margin: 0.3rem 0 0; max-width: 75ch; } +.periode { font-size: 0.8125rem; color: var(--or-fonce); font-weight: 600; margin: 0 0 0.15rem !important; } +.employeur { color: var(--gris); } + +.commentaire { border-top: 1px solid var(--trait); padding-top: 0.7rem; margin-top: 0.7rem; } +.commentaire p { margin: 0; } +.auteur { font-size: 0.8125rem; color: var(--gris); margin-bottom: 0.15rem !important; } +.historique { list-style: none; padding: 0 !important; margin: 0 0 0.8rem; display: grid; gap: 0.55rem; font-size: 0.875rem; } +.historique span { display: block; font-size: 0.8125rem; color: var(--gris); } + +.groupe-mots { margin: 0.7rem 0 0.3rem; font-size: 0.8125rem; color: var(--gris); } +.mots { list-style: none; display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0; padding: 0 !important; } +.mots li { display: inline-flex; align-items: center; background: var(--fond); border-radius: 3px; padding: 0.1rem 0.15rem 0.1rem 0.5rem; font-size: 0.8125rem; } +.mots li.manuel { background: var(--or-pale); } +.mots form { display: inline-flex; } +.mots button { min-height: 1.5rem; min-width: 1.5rem; padding: 0; background: none; border: 0; color: var(--gris); font-weight: 700; } +.mots button:hover { background: none; color: var(--brique); } +.ajout-mot { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 9rem) auto; gap: 0.5rem; align-items: end; margin-top: 1rem; } + +/* Recherche -------------------------------------------------------------- */ +.resultats { list-style: none; margin: 0; padding: 0; } +.resultats .carte { margin-bottom: 0.6rem; padding: 0.9rem 1.2rem; } +.resultat-tete { display: flex; justify-content: space-between; align-items: baseline; gap: 0.3rem 1rem; flex-wrap: wrap; } +.resultats .nom { font: 500 1.35rem/1.2 var(--titre); color: var(--encre); text-decoration: none; } +.resultats .nom:hover { text-decoration: underline; } +.resultats .sous-titre { font-size: 0.875rem; margin-top: 0.3rem; } + +/* Comptes ---------------------------------------------------------------- */ +.comptes { list-style: none; margin: 0; padding: 0; } +.comptes .carte.inactif { background: #f6f7f8; } +.compte-nom { font-weight: 600; font-size: 1.0625rem; margin: 0 0 0.2rem; } +.compte-meta { display: flex; flex-wrap: wrap; gap: 0.2rem 1.1rem; color: var(--gris); font-size: 0.875rem; margin-bottom: 0.7rem; } +.compte-meta strong { color: var(--encre); } + +/* Tableau de bord -------------------------------------------------------- */ +.chiffres { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; margin: 0 0 1.25rem; background: var(--noir); color: #fff; border-radius: 6px; overflow: hidden; } +.chiffres div { padding: 1.1rem 1.3rem 1.2rem; border-left: 1px solid #333; display: flex; flex-direction: column; justify-content: flex-start; gap: 0.4rem; } +.chiffres div:first-child { border-left: 0; } +.chiffres dt { color: #c9c9c9; font-size: 0.875rem; } +.chiffres dt a { color: inherit; } +.chiffres dd { order: -1; margin: 0; font: 500 3rem/1 var(--titre); color: var(--or); font-variant-numeric: lining-nums; } + +.mesures th { color: var(--encre); font-size: 0.9375rem; font-weight: 400; white-space: nowrap; width: 1%; vertical-align: middle; } +.mesures th:has(.echelle) { position: relative; padding-left: 1.6rem; } +.mesures th .echelle { position: absolute; left: 0; top: 50%; transform: translateY(-50%); } +.mesures td { vertical-align: middle; } +.mesures .nombre { width: 1%; font-weight: 600; } +.mesures .nombre small { font-weight: 400; margin-left: 0.3rem; } +.barre { display: block; height: 0.6rem; background: var(--fond); min-width: 4rem; } +.barre span { display: block; height: 100%; background: var(--noir); } +.l0 { width: 0; } .l1 { width: 5%; } .l2 { width: 10%; } .l3 { width: 15%; } .l4 { width: 20%; } .l5 { width: 25%; } +.l6 { width: 30%; } .l7 { width: 35%; } .l8 { width: 40%; } .l9 { width: 45%; } .l10 { width: 50%; } +.l11 { width: 55%; } .l12 { width: 60%; } .l13 { width: 65%; } .l14 { width: 70%; } .l15 { width: 75%; } +.l16 { width: 80%; } .l17 { width: 85%; } .l18 { width: 90%; } .l19 { width: 95%; } .l20 { width: 100%; } + +/* Rapport ---------------------------------------------------------------- */ +.rapport h2 { font: 500 1.75rem/1.15 var(--titre); margin: 0 0 1rem; } +.rapport h3 { font: 600 1.0625rem/1.3 var(--texte); margin: 1.5rem 0 0.5rem; padding-bottom: 0.3rem; border-bottom: 2px solid var(--or); } +.rapport h4, .rapport h5 { font-size: 1rem; margin: 1rem 0 0.3rem; } +.rapport p, .rapport li { max-width: 75ch; } +.rapport ul, .rapport ol { margin: 0 0 0.8rem; padding-left: 1.3rem; } +.rapport pre { background: var(--fond); padding: 0.7rem; border-radius: 4px; overflow-x: auto; } +.rapport table { margin-bottom: 1rem; } + +dialog { border: 1px solid var(--encre); border-radius: 6px; padding: 1.3rem; width: min(27rem, 92vw); } +dialog::backdrop { background: rgba(0, 0, 0, 0.55); } + +/* Écrans étroits --------------------------------------------------------- */ +@media (max-width: 1180px) { + .entete { display: block; position: static; } + .entete-barre { min-height: 3.25rem; } + .menu-bouton { + display: inline-flex; min-height: 2.15rem; padding: 0.25rem 0.9rem; + background: none; border: 1px solid #777; color: #fff; + } + .menu-bouton:hover { background: #222; border-color: #fff; color: #fff; } + .menu-bouton[aria-expanded="true"] { background: var(--or); border-color: var(--or); color: var(--noir); } + .entete-menu { display: none; flex-direction: column; align-items: stretch; gap: 0.8rem; padding: 0.2rem 0 1.1rem; } + .entete-menu.ouvert { display: flex; animation: derouler 0.16s ease-out; } + .entete nav { flex-direction: column; gap: 0; font-size: 1rem; } + .entete nav > a, .menu-plus > summary { min-height: 2.9rem; border-bottom: 1px solid #2c2c2c; box-shadow: inset 3px 0 0 transparent; justify-content: space-between; } + .entete nav > a[aria-current], .menu-plus.actif > summary { box-shadow: inset 3px 0 0 var(--or); padding-left: 0.75rem; } + .menu-plus > summary::after { margin-right: 0.3rem; } + .menu-plus-liste, .menu-compte .menu-plus-liste { position: static; min-width: 0; border: 0; box-shadow: none; background: none; padding: 0.2rem 0 0.4rem 0.75rem; animation: none; } + .recherche-rapide input[type="search"], .recherche-rapide input[type="search"]:focus { flex: 1; width: auto; min-height: 2.6rem; } + .bouton.bouton-entete { min-height: 2.6rem; } + .menu-compte > summary { border-bottom: 0; border-top: 1px solid #2c2c2c; } + .menu-compte .nom-compte { flex: 1; } +} + +@media (max-width: 900px) { + .deux-colonnes, .deux-colonnes.inverse { grid-template-columns: minmax(0, 1fr); } + .kanban { grid-auto-columns: min(82vw, 20rem); scroll-snap-type: x mandatory; margin-right: calc(-1 * var(--gouttiere)); padding-right: var(--gouttiere); } + .colonne h2 { min-height: 0; } +} + +@media (max-width: 640px) { + .carte { padding: 1rem; } + .champs { grid-template-columns: minmax(0, 1fr); } + .ajout-mot { grid-template-columns: minmax(0, 1fr); } + .grille, .grille.serre { grid-template-columns: minmax(0, 1fr); gap: 0.1rem; } + .grille dd { margin-bottom: 0.5rem; } + .filtres label, .filtres label.large, .statut-form label, .statut-form label + label { flex: 1 1 100%; } + .filtres > button, .filtres > .bouton, .statut-form > button { flex: 1 1 100%; } + .filtres .fin { margin-left: 0; } + .exports { width: 100%; } + .exports .bouton { flex: 1 1 calc(50% - 0.25rem); } + .actions > * { flex: 1 1 auto; } + .echelle-grande i { width: 24px; height: 4px; } + .chiffres { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .chiffres div { border-left: 0; border-top: 1px solid #333; } + .chiffres div:nth-child(-n+2) { border-top: 0; } + .chiffres div:nth-child(even) { border-left: 1px solid #333; } + .chiffres div:last-child:nth-child(odd) { grid-column: 1 / -1; } + .chiffres dd { font-size: 2.4rem; } + .barre-titre .filtres label { flex: 1 1 40%; min-width: 0; } + .mesures th { white-space: normal; } + .barre { min-width: 2.5rem; } + th, td { padding-right: 0.6rem; } +} + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; scroll-behavior: auto !important; } +} + +@media print { + .entete, .filtres, .exports, .barre-statut form { display: none; } + body { background: #fff; } +} + +/* Portraits -------------------------------------------------------------- */ +.avatar { display: inline-grid; place-items: center; flex: none; width: 2.25rem; height: 2.25rem; border-radius: 50%; object-fit: cover; object-position: center 20%; background: var(--fond); } +.avatar-vide { font-size: 0.75rem; font-weight: 600; color: var(--gris); border: 1px solid var(--trait); letter-spacing: 0.02em; } +.avatar-moyen { width: 3.5rem; height: 3.5rem; } +.avatar-moyen.avatar-vide { font-size: 1.05rem; } +.avatar-grand { width: 6rem; height: 6rem; border-radius: 6px; } +.avatar-grand.avatar-vide { font: 500 2rem var(--titre); } +.fiche-tete { display: flex; gap: 0.6rem; align-items: flex-start; } +.fiche-tete > div { min-width: 0; } +.fiche-tete a { font-weight: 600; color: var(--encre); text-decoration: none; } +.fiche-tete a:hover { text-decoration: underline; } +.resultat { display: flex; gap: 1rem; align-items: flex-start; } +.resultat-corps { flex: 1; min-width: 0; } +.relecture { display: flex; gap: 1.5rem; align-items: flex-start; flex-wrap: wrap; } +.relecture .grille { flex: 1 1 20rem; } +.relecture-photo { display: grid; gap: 0.5rem; justify-items: start; } +label.case { display: flex; align-items: center; gap: 0.45rem; font-weight: 400; } +label.case input { width: 1.1rem; height: 1.1rem; } + +/* Choix de la langue ----------------------------------------------------- */ +.langues { display: inline-flex; border: 1px solid #555; border-radius: 4px; overflow: hidden; } +.langues button { min-height: 1.75rem; padding: 0.05rem 0.55rem; background: none; border: 0; border-radius: 0; color: #d9d9d9; font-size: 0.8125rem; font-weight: 600; } +.langues button:hover { background: #2a2a2a; color: #fff; } +.langues button[aria-pressed="true"] { background: var(--or); color: var(--noir); } +.langues-accueil { position: absolute; top: 1rem; right: 1rem; } +@media (max-width: 640px) { + .avatar-grand { width: 4.5rem; height: 4.5rem; } + .resultat { gap: 0.7rem; } + .avatar-moyen { width: 2.75rem; height: 2.75rem; } +} + +/* Version 1.4 : suivi, besoins, lot, sauvegarde -------------------------- */ +input[type="date"], input[type="time"] { + width: 100%; min-height: 2.6rem; padding: 0.5rem 0.7rem; font-weight: 400; + background: #fff; border: 1px solid #98a1a8; border-radius: 4px; +} +h2 small { font-weight: 400; color: var(--gris); margin-left: 0.3rem; } +.barre-titre h2 { margin: 0; } +.carte.inactif { background: #f6f7f8; } +.actions.gauche { justify-content: flex-start; } +details.ajout { margin: 0.9rem 0 0; padding-top: 0.6rem; border-top: 1px solid var(--trait); } +.depot { margin-bottom: 1rem; } + +/* Repères sur les cartes du Kanban */ +.fiche .reperes { margin: 0.45rem 0 0; padding-top: 0.4rem; border-top: 1px solid var(--trait); font-size: 0.8125rem; display: grid; gap: 0.1rem; color: var(--or-fonce); font-weight: 600; } + +/* Badges d'état */ +.badge-attente { background: var(--or-pale); border-color: #cbbb62; color: #4d4204; } +.badge-avis-favorable, .badge-besoin-pourvu, .badge-proposition-retenu, .badge-lot-pret { background: #e5f2ea; border-color: #7fb597; color: #1d5a37; } +.badge-avis-reserve, .badge-proposition-presente, .badge-lot-analyse { background: var(--or-pale); border-color: #cbbb62; color: #4d4204; } +.badge-avis-defavorable, .badge-lot-erreur { background: var(--brique-pale); border-color: #d99a8c; color: var(--brique); } +.badge-besoin-ouvert { background: var(--noir); border-color: var(--noir); color: var(--or); } +.badge-besoin-ferme, .badge-proposition-ecarte, .badge-lot-en_attente { color: var(--gris); } +.etat .badge { margin-right: 0.2rem; } + +/* Entretiens et relances sur la fiche */ +.entretien { padding: 0.9rem 0; border-top: 1px solid var(--trait); } +.entretien:first-of-type { border-top: 0; padding-top: 0; } +.entretien p { margin: 0.3rem 0 0; max-width: 75ch; } +.entretien .periode { display: flex; flex-wrap: wrap; gap: 0.3rem 0.6rem; align-items: center; } +.entretien details { margin: 0.5rem 0 0; } +.relances li.faite, .agenda li.faite { color: var(--gris); } +.relances li.faite > span { text-decoration: line-through; } +.bandeau-archive { display: flex; gap: 0.8rem 1.5rem; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.bandeau-archive p { margin: 0; } + +/* Listes avec portrait : suivi, propositions, rapprochement */ +.agenda li { display: flex; gap: 0.75rem; align-items: flex-start; padding-bottom: 0.7rem; border-bottom: 1px solid var(--trait); } +.agenda li:last-child { border-bottom: 0; padding-bottom: 0; } +.agenda li > div { flex: 1; min-width: 0; display: grid; gap: 0.2rem; } +.agenda li > div > a { font-weight: 600; color: var(--encre); justify-self: start; } +.agenda p { margin: 0; } +.agenda .technos { margin: 0.15rem 0; } +.agenda details { margin: 0.2rem 0 0; } +.agenda .badge { justify-self: start; } +.rapprochement li > div { position: relative; padding-right: 4.2rem; } +.note-rapprochement { position: absolute; right: 0; top: 0; font: 500 1.6rem/1 var(--titre); color: var(--encre); font-variant-numeric: lining-nums; } +.note-rapprochement small { font: 400 0.75rem var(--texte); color: var(--gris); margin-left: 0.1rem; } +.technos span.absent { background: #fff; border: 1px dashed #98a1a8; color: var(--gris); } +.description-besoin { margin-top: 0.8rem; max-width: 75ch; } +.besoin-etroit .champs { grid-template-columns: minmax(0, 1fr); } +.entete-profil .statut-form label { flex: 0 1 12rem; } + +/* Import en lot */ +table.lot td { vertical-align: middle; } +table.lot .detail-lot { margin: 0.3rem 0 0; font-size: 0.8125rem; font-weight: 400; max-width: 46ch; } + +@media (max-width: 640px) { + .rapprochement li > div { padding-right: 0; } + .note-rapprochement { position: static; } +} + +.deux-egales { margin-bottom: 1rem; } +.carte > form + form { margin-top: 0.8rem; } +.actions-ligne.droite { display: flex; justify-content: flex-end; } +.entete-profil .statut-form { flex-wrap: nowrap; } + +/* Finitions --------------------------------------------------------------- + Le mouvement répond à un geste : ouvrir, déposer, confirmer. Rien ne bouge tout seul. */ +@view-transition { navigation: auto; } +::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.14s; } +html { scroll-behavior: smooth; scroll-padding-top: 4.5rem; } +::selection { background: var(--or); color: var(--noir); } + +.carte { border-radius: 8px; box-shadow: 0 1px 2px rgba(28, 28, 26, 0.04); } +.carte h2 { font-size: 1.0625rem; } +.barre-statut { border-radius: 8px; } + +button, .bouton { transition: background-color 0.12s, border-color 0.12s, color 0.12s, transform 0.06s; } +button:active, .bouton:active { transform: translateY(1px); } +button.lien:active { transform: none; } +input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="date"], input[type="time"], select, textarea { transition: border-color 0.12s, box-shadow 0.12s; } +main input[type="text"]:focus, main input[type="email"]:focus, main input[type="password"]:focus, main input[type="search"]:focus, main input[type="date"]:focus, main input[type="time"]:focus, main select:focus, main textarea:focus { + border-color: var(--petrole); box-shadow: 0 0 0 3px rgba(13, 74, 93, 0.16); outline: 0; +} +body.accueil main input:focus { border-color: var(--or); box-shadow: 0 0 0 3px rgba(206, 183, 59, 0.25); } + +/* Blocs repliables : une flèche qui tourne, un contenu qui se déroule */ +summary { list-style: none; display: flex; align-items: center; gap: 0.45rem; } +summary::-webkit-details-marker { display: none; } +main summary::before { content: ""; flex: none; width: 0.4rem; height: 0.4rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform 0.15s; } +main details[open] > summary::before { transform: translateY(-0.1rem) rotate(45deg); } +main details[open] > :not(summary) { animation: derouler 0.16s ease-out; } +dialog[open] { animation: derouler 0.16s ease-out; } + +/* Messages : ils arrivent, se ferment d'un clic, et les confirmations s'effacent seules */ +.flash { animation: derouler 0.2s ease-out; } +.flash.fermable { display: flex; gap: 1rem; align-items: flex-start; justify-content: space-between; } +.flash .fermer { flex: none; min-height: 0; width: 1.6rem; height: 1.6rem; padding: 0; margin: -0.1rem -0.3rem 0 0; background: none; border: 0; border-radius: 50%; color: inherit; font-size: 1.15rem; line-height: 1; opacity: 0.6; } +.flash .fermer:hover { background: rgba(0, 0, 0, 0.08); opacity: 1; } +.flash.part { opacity: 0; transform: translateY(-4px); transition: opacity 0.25s, transform 0.25s; } + +tbody tr { transition: background-color 0.1s; } +.defile tbody tr:hover { background: #f5f7f8; } + +/* Kanban : des couloirs visibles, une carte qui se soulève, une zone de dépôt qui s'annonce */ +.kanban { gap: 0.6rem; } +.colonne { background: rgba(255, 255, 255, 0.45); border-radius: 8px; padding: 0.7rem 0.55rem 0.55rem; } +.colonne h2 { padding: 0 0.2rem 0.6rem; min-height: 2.9rem; } +.cartes { min-height: 6rem; border-radius: 6px; transition: background-color 0.12s, box-shadow 0.12s; } +.cartes:not(:has(.fiche:not(.cachee)))::after { content: attr(data-vide); display: block; padding: 1.2rem 0.5rem; text-align: center; color: #8a9096; font-size: 0.8125rem; border: 1px dashed #c4cace; border-radius: 6px; } +.fiche { border-radius: 6px; transition: border-color 0.12s, box-shadow 0.12s; } +.kanban[data-modifiable="1"] .fiche:hover { box-shadow: 0 2px 6px rgba(28, 28, 26, 0.08); } +.kanban.en-glisse .cartes { background: rgba(206, 183, 59, 0.1); box-shadow: inset 0 0 0 1px rgba(206, 183, 59, 0.55); } +.fiche.saisie { cursor: grabbing; } +.fiche.glisse { box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18); transform: rotate(1.2deg); border-color: var(--encre); } +.fantome { opacity: 1; background: var(--or-pale); border: 1px dashed #b8a22f; } +.fantome > * { visibility: hidden; } + +.resultats .carte { transition: border-color 0.12s; } +.resultats .carte:hover { border-color: #98a1a8; } + +/* Fiche profil : bandeau, onglets, panneaux ------------------------------ + Le bandeau reste en tête de fiche. Les onglets restent collés sous l'en-tête. + Sans JavaScript, les onglets servent de sommaire et les quatre panneaux s'enchaînent. */ +.barre-statut-ligne { display: flex; flex-wrap: wrap; gap: 0.9rem 2.5rem; align-items: flex-end; justify-content: space-between; } +.barre-statut-ligne .statut-form { flex: 1 1 26rem; } +.statut-lecture { margin: 0; } +.reperes-profil { display: flex; flex-wrap: wrap; gap: 0.5rem 2rem; margin: 0; } +.reperes-profil div { display: grid; gap: 0.1rem; min-width: 0; } +.reperes-profil dt { color: var(--gris); font-size: 0.8125rem; } +.reperes-profil dd { margin: 0; font-weight: 600; } +.reperes-profil dd em { font-weight: 400; } +.motif-profil { margin: 0.8rem 0 0; padding-top: 0.7rem; border-top: 1px solid var(--trait); color: var(--brique); } +.a-faire { list-style: none; margin: 0.8rem 0 0 !important; padding: 0.75rem 0 0 !important; border-top: 1px solid var(--trait); display: flex; flex-wrap: wrap; gap: 0.5rem; } +.a-faire a { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.3rem 0.8rem; border-radius: 999px; background: var(--fond); color: var(--encre); text-decoration: none; font-size: 0.875rem; font-weight: 600; transition: box-shadow 0.12s; } +.a-faire a::before { content: ""; flex: none; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--or); } +.a-faire .urgent a { background: var(--brique-pale); color: var(--brique); } +.a-faire .urgent a::before { background: var(--brique); } +.a-faire a:hover { box-shadow: inset 0 0 0 1px currentColor; color: inherit; } +.a-faire .urgent a:hover { color: var(--brique); } + +/* CV IT Club : un bouton, deux versions */ +.menu-deroulant { position: relative; margin: 0; } +.menu-deroulant > summary { padding: 0.5rem 1.1rem; } +main .menu-deroulant > summary::before { display: none; } +.menu-deroulant > summary::after { content: ""; flex: none; width: 0.36rem; height: 0.36rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-0.12rem) rotate(45deg); transition: transform 0.15s; } +.menu-deroulant[open] > summary { margin-bottom: 0; } +.menu-deroulant[open] > summary::after { transform: translateY(0.1rem) rotate(225deg); } +.menu-deroulant-liste { position: absolute; right: 0; top: calc(100% + 0.3rem); z-index: 20; min-width: 12rem; display: grid; padding: 0.3rem; background: #fff; border: 1px solid var(--trait); border-radius: 8px; box-shadow: 0 12px 28px rgba(28, 28, 26, 0.14); } +.menu-deroulant-liste a { display: block; padding: 0.55rem 0.75rem; border-radius: 4px; color: var(--encre); text-decoration: none; } +.menu-deroulant-liste a:hover { background: var(--fond); color: var(--noir); } + +.onglets { position: sticky; top: 3.25rem; z-index: 5; display: flex; gap: 0.15rem; margin: 0 calc(-1 * var(--gouttiere)) 1.25rem; padding: 0 var(--gouttiere); overflow-x: auto; background: rgba(236, 239, 241, 0.94); backdrop-filter: blur(6px); border-bottom: 1px solid var(--trait); scrollbar-width: none; } +.onglets::-webkit-scrollbar { display: none; } +.onglets a { flex: none; display: inline-flex; align-items: center; gap: 0.45rem; min-height: 2.9rem; padding: 0 1rem; color: var(--gris); text-decoration: none; font-weight: 600; font-size: 0.9375rem; white-space: nowrap; box-shadow: inset 0 -3px 0 transparent; transition: color 0.12s, box-shadow 0.12s; } +.onglets a:hover { color: var(--encre); box-shadow: inset 0 -3px 0 var(--trait); } +.onglets a[aria-selected="true"] { color: var(--noir); box-shadow: inset 0 -3px 0 var(--or); } +.onglets a:focus-visible { outline-offset: -3px; } +.onglets small { color: var(--gris); font-weight: 400; font-size: 0.8125rem; font-variant-numeric: tabular-nums; } +.pastille-alerte { background: var(--brique); color: #fff; } +html:has(.onglets) { scroll-padding-top: 7rem; } + +.titre-panneau { font: 500 1.35rem/1.2 var(--titre); margin: 1.75rem 0 0.9rem; } +.panneau:first-of-type .titre-panneau { margin-top: 0; } +.avec-onglets .titre-panneau { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; margin: 0; } +.avec-onglets .panneau:not([hidden]) { animation: derouler 0.16s ease-out; } +.panneau:focus { outline: 0; } + +details.suite { margin: 0.9rem 0 0; padding-top: 0.6rem; border-top: 1px solid var(--trait); } +details.suite .experience:first-of-type { padding-top: 0.2rem; } +details.suite table { margin-top: 0.2rem; } +.suite-historique { margin-top: 0.6rem; } + +@media (max-width: 1180px) { + .onglets { top: 0; } + html:has(.onglets) { scroll-padding-top: 3.6rem; } + html { scroll-padding-top: 1rem; } +} +@media (max-width: 640px) { + .onglets a { padding: 0 0.75rem; } + .barre-statut-ligne { gap: 0.9rem; } + .barre-statut-ligne .statut-form { flex-basis: 100%; } + .reperes-profil { gap: 0.5rem 1.5rem; } + .exports .menu-deroulant { flex: 1 1 calc(50% - 0.25rem); } + .exports .menu-deroulant > summary { width: 100%; } + .menu-deroulant-liste { left: 0; right: auto; } +} +@media (prefers-reduced-motion: reduce) { + @view-transition { navigation: none; } + html { scroll-behavior: auto; } +} +@media print { + .onglets { display: none; } + .avec-onglets .panneau[hidden] { display: block !important; } + .avec-onglets .titre-panneau { position: static; width: auto; height: auto; clip: auto; margin: 1.5rem 0 0.8rem; } +} + +/* Aide --------------------------------------------------------------------- */ +.lien-aide { flex: none; display: grid; place-items: center; width: 1.9rem; height: 1.9rem; border-radius: 50%; border: 1px solid #6a6a6a; color: #fff; text-decoration: none; font-weight: 600; font-size: 0.9375rem; transition: border-color 0.12s, color 0.12s; } +.lien-aide:hover, .lien-aide[aria-current] { border-color: var(--or); color: var(--or); } +.lien-aide b { display: none; font-weight: 400; } +.aide-tete { align-items: center; } +.aide-outils { display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; } +.versions { display: inline-flex; border: 1px solid #98a1a8; border-radius: 4px; overflow: hidden; background: #fff; } +.versions a { padding: 0.45rem 0.8rem; color: var(--encre); text-decoration: none; font-weight: 600; font-size: 0.875rem; } +.versions a[aria-current] { background: var(--noir); color: var(--or); } +.aide-page { display: grid; grid-template-columns: 17rem minmax(0, 1fr); gap: 1.5rem; align-items: start; } +.aide-sommaire { position: sticky; top: 4.25rem; max-height: calc(100vh - 5.25rem); overflow-y: auto; font-size: 0.9375rem; } +.aide-sommaire ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.1rem; } +.aide-sommaire ol a { display: block; padding: 0.35rem 0.7rem; border-left: 2px solid var(--trait); color: var(--encre); text-decoration: none; line-height: 1.3; transition: border-color 0.12s, background-color 0.12s; } +.aide-sommaire ol a:hover { border-left-color: var(--or); background: #fff; } +.aide-guides { display: grid; gap: 0.3rem; margin: 0 0 1rem; } +.aide-guides a { padding: 0.45rem 0.7rem; border: 1px solid var(--trait); border-radius: 6px; background: #fff; color: var(--encre); text-decoration: none; font-weight: 600; } +.aide-guides a[aria-current] { background: var(--noir); border-color: var(--noir); color: var(--or); } +.aide-texte { padding: 1.6rem 2rem 2rem; margin: 0; } +.aide-texte h2 { margin-top: 2.6rem; padding-top: 1.6rem; border-top: 1px solid var(--trait); } +.aide-texte h2:first-child { margin-top: 0; padding-top: 0; border-top: 0; } +.aide-texte h3 { margin-top: 1.7rem; } +.aide-texte p, .aide-texte li { max-width: 72ch; line-height: 1.6; } +.aide-texte li { margin-bottom: 0.25rem; } +.aide-texte .defile { margin: 0.4rem 0 1.1rem; } +.aide-texte table { max-width: 60rem; } +.aide-texte th { white-space: nowrap; } +.aide-texte td:first-child { white-space: nowrap; font-weight: 600; } +.aide-texte code { white-space: nowrap; } + +@media (max-width: 1180px) { + .lien-aide { width: auto; height: auto; min-height: 2.6rem; display: flex; justify-content: flex-start; border: 0; border-radius: 0; border-top: 1px solid #2c2c2c; font-weight: 400; font-size: 1rem; } + .lien-aide span { display: none; } + .lien-aide b { display: inline; } +} +@media (max-width: 900px) { + .aide-page { grid-template-columns: minmax(0, 1fr); } + .aide-sommaire { position: static; max-height: none; } + .aide-sommaire ol { grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); } + .aide-texte { padding: 1.1rem 1rem 1.4rem; } + .aide-texte td:first-child, .aide-texte th, .aide-texte code { white-space: normal; } +} +@media print { + .aide-page { display: block; } + .aide-sommaire, .aide-outils, .evitement, main > .flash { display: none; } + .aide-texte { border: 0; box-shadow: none; padding: 0; } + .aide-texte h2 { break-after: avoid; } + .aide-texte h3 { break-after: avoid; } + .aide-texte table, .aide-texte li { break-inside: avoid; } + .aide-texte .defile { overflow: visible; } +} + +/* Les guides et les rapports gardent leurs grands titres */ +.carte.rapport h2 { font-size: 1.75rem; } +.carte.rapport h3 { font-size: 1.125rem; } +/* Version 1.5 : demande de compte, validation, corbeille ------------------ */ +.porte-lien { margin: 1.4rem 0 0; font-size: 0.9375rem; } +.porte-lien a { color: var(--or); } +.porte-lien a:hover { color: #fff; } +/* Champ piège de la demande de compte : hors de l'écran, ignoré des lecteurs d'écran et du clavier */ +.piege { position: absolute; left: -999rem; width: 1px; height: 1px; overflow: hidden; } +.carte.demandes { border-left: 4px solid var(--or); } +.carte.demandes h2 small { color: var(--gris); font-weight: 400; } +.liste-demandes { list-style: none; margin: 0; padding: 0; } +.liste-demandes li { display: flex; flex-wrap: wrap; gap: 0.6rem 1.5rem; align-items: center; justify-content: space-between; padding: 0.8rem 0; border-top: 1px solid var(--trait); } +.liste-demandes .compte-meta { margin-bottom: 0; } +.compte-menu { color: #9a9a9a; font-weight: 400; } +.profil-ligne { display: flex; gap: 0.7rem; align-items: center; } +.profil-ligne .avatar { flex: none; } +p.aide.danger { margin-bottom: 0; } +@page { margin: 16mm 15mm; } +@media print { main { padding: 0; max-width: none; } .aide-tete h1 { font-size: 26pt; margin-bottom: 6mm; } } + +/* Version 1.7 : finitions d'usage ------------------------------------------ */ +/* Un bouton d'état ne se coupe pas sur deux lignes */ +.statut-form button { white-space: nowrap; } + +/* Profils : sur écran étroit, les filtres se replient derrière un bouton (le script l'ajoute) */ +.bouton-filtres { display: none; } +@media (max-width: 640px) { + .bouton-filtres { display: inline-flex; flex: 1 1 100%; } + .bouton-filtres[aria-expanded="true"] { background: var(--encre); color: #fff; } + form[data-repli].replie > label:not(.large) { display: none; } +} + +/* Écran tactile : des cibles d'au moins 24 px pour les liens d'action et les noms cliquables */ +@media (pointer: coarse), (max-width: 640px) { + button.lien { min-height: 1.75rem; } + .fiche > a, .fiche-tete a, .agenda li > div > a, .resultat a.nom { display: inline-block; padding: 0.15rem 0; } + .mots button { min-width: 1.75rem; min-height: 1.75rem; } +} + +/* Formulaire de la fiche : trois blocs titrés */ +fieldset.groupe { border: 0; border-top: 1px solid var(--trait); margin: 0; padding: 1.1rem 0 0; min-width: 0; } +fieldset.groupe legend { font: 600 1.0625rem/1.3 var(--texte); padding: 0 0.6rem 0 0; } +fieldset.groupe > .aide { margin: 0.2rem 0 0.9rem; } +.aide-champ { font-weight: 400; color: var(--gris); font-size: 0.8125rem; } + +.champs > label { align-content: start; } + +/* Version 1.8 : sécurité des accès, traductions, nationalité, Europass ------ */ +/* Pages d'entrée : connexion, code, invitation, double authentification */ +.porte-note { margin: 1rem 0 0; color: #bdbdbd; font-size: 0.875rem; text-align: left; } +.porte-texte { text-align: left; color: #e6e6e6; } +.porte-texte p { margin: 0 0 0.8rem; } +.porte-details { margin: 1.2rem 0 0; text-align: left; color: #d0d0d0; font-size: 0.9375rem; } +.porte-details summary { cursor: pointer; color: var(--or); } +.porte-details p { margin: 0.6rem 0 0; } +body.accueil main:has(.porte-large) { max-width: 34rem; } +.code-saisie { font: 600 1.5rem/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: 0.25em; text-align: center; } +.porte input.code-saisie { background: #151515; border-color: #555; color: #fff; } +.etapes-2fa { counter-reset: etape; list-style: none; margin: 0; padding: 0; display: grid; gap: 1.1rem; text-align: left; } +.etapes-2fa > li { counter-increment: etape; position: relative; padding-left: 2.4rem; display: grid; gap: 0.5rem; justify-items: start; } +.etapes-2fa > li::before { + content: counter(etape); position: absolute; left: 0; top: 0; width: 1.7rem; height: 1.7rem; border-radius: 50%; + display: grid; place-items: center; background: var(--or); color: var(--noir); font-weight: 700; font-size: 0.875rem; +} +.etapes-2fa > li > span { color: var(--gris); font-size: 0.9375rem; } +.porte .etapes-2fa > li > span { color: #c9c9c9; } +.etapes-2fa form { width: 100%; } +.qr { display: block; width: 12rem; max-width: 100%; aspect-ratio: 1; background: #fff; border-radius: 6px; padding: 0.3rem; } +.qr svg { display: block; width: 100%; height: 100%; } +.cle-2fa { display: inline-block; font-size: 1rem; letter-spacing: 0.08em; padding: 0.35rem 0.6rem; user-select: all; overflow-wrap: anywhere; } +.porte .cle-2fa { background: #151515; color: #fff; border: 1px solid #555; } +.codes-secours { list-style: none; margin: 0 0 1rem; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: 0.5rem; } +.codes-secours code { display: block; text-align: center; font-size: 1.0625rem; padding: 0.5rem; letter-spacing: 0.06em; border: 1px dashed var(--trait); } + +/* Page publique : guide Sécurité lu sans être connecté */ +body.public { background: var(--fond); } +.entete-public { justify-content: space-between; } + +/* Comptes et invitations */ +.intertitre { margin: 1.1rem 0 0.6rem; font-size: 0.9375rem; } +.ajout-ligne { display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: flex-end; margin: 0 0 0.8rem; } +.ajout-ligne label { flex: 1 1 16rem; } +.lien-invitation { background: var(--or-pale); border-left: 4px solid var(--or); padding: 0.8rem 1rem; margin: 0 0 1rem; border-radius: 4px; } +.lien-invitation p { margin: 0 0 0.5rem; } +.copie { display: flex; gap: 0.5rem; } +.copie input { flex: 1 1 auto; min-width: 0; font: 0.875rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #fff; } +.copie button { flex: none; } +.ligne-action { display: flex; flex-wrap: wrap; gap: 0.3rem 1rem; align-items: baseline; justify-content: space-between; } +.ligne-action form { display: inline; } +.connexions-courtes li { font-size: 0.9375rem; } +.bloc-action { margin: 0.9rem 0 0.4rem; } +.comptes details { margin: 0.4rem 0 0; } +.comptes details > summary { cursor: pointer; color: var(--petrole); font-size: 0.9375rem; } +.comptes details[open] > summary { margin-bottom: 0.6rem; } +.comptes .actions-ligne { margin-top: 0.8rem; } + +/* Profil : traduction et mission */ +.bandeau-traduction { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center; justify-content: space-between; } +.bandeau-traduction p { margin: 0; flex: 1 1 24rem; } +.bandeau-traduction form { flex: none; } +.note-traduction { color: var(--gris); font-size: 0.8125rem; margin: -0.4rem 0 0.9rem; } +.note-traduction a { margin-left: 0.4rem; } +.cadre-mission { color: var(--gris); font-size: 0.875rem; margin: 0.2rem 0 0.4rem; } +.mission { font-style: italic; } +.permis-libre { margin: 0 0 0.9rem; } + +@media print { + body:has(.codes-secours) .entete, body:has(.codes-secours) .actions { display: none; } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..754e059 --- /dev/null +++ b/public/index.php @@ -0,0 +1,1635 @@ + (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')]); +} diff --git a/sql/.htaccess b/sql/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/sql/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/sql/migration-1.0-vers-1.1.sql b/sql/migration-1.0-vers-1.1.sql new file mode 100644 index 0000000..f52e2bf --- /dev/null +++ b/sql/migration-1.0-vers-1.1.sql @@ -0,0 +1,62 @@ +-- Vivier : passage d'une base 1.0 à 1.1 +-- À exécuter une seule fois dans phpMyAdmin, sur une base créée avec l'ancien schema.sql. +-- Ensuite, dans Vivier : menu Comptes, bouton « Réindexer tous les profils ». + +SET NAMES utf8mb4; + +ALTER TABLE candidats + ADD COLUMN niveau_etudes VARCHAR(20) NULL AFTER disponibilite, + ADD COLUMN encadrement TINYINT(1) NULL AFTER niveau_etudes; + +-- --------------------------------------------------------------- +-- Mots-clés et métadonnées de recherche +-- Fournis par l'analyse (manuel = 0) ou ajoutés à la main sur la fiche (manuel = 1). +-- --------------------------------------------------------------- +CREATE TABLE mots_cles ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + type ENUM('role','specialite','secteur','mot_cle') NOT NULL, + mot VARCHAR(80) NOT NULL, + manuel TINYINT(1) NOT NULL DEFAULT 0, + UNIQUE KEY uq_mot (candidat_id, type, mot), + CONSTRAINT fk_mot_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Index de recherche : un terme normalisé par ligne, avec son poids. +-- Reconstruit par PHP à chaque import ou modification d'un profil. +-- --------------------------------------------------------------- +CREATE TABLE index_recherche ( + candidat_id INT UNSIGNED NOT NULL, + terme VARCHAR(60) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + poids SMALLINT UNSIGNED NOT NULL, + PRIMARY KEY (candidat_id, terme), + KEY idx_terme (terme), + CONSTRAINT fk_index_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Échecs de connexion récents : blocage temporaire après 5 essais +-- --------------------------------------------------------------- +CREATE TABLE tentatives_connexion ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + cle VARCHAR(190) NOT NULL, + moment DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_cle (cle, moment) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO synonymes_recherche (terme, cible_type, cible) VALUES + ('développeuse','texte', 'développeur'), + ('fullstack','texte', 'full-stack'), + ('backend', 'texte', 'back-end'), + ('frontend','texte', 'front-end'), + ('js', 'texte', 'javascript'), + ('ts', 'texte', 'typescript'), + ('postgres','texte', 'postgresql'), + ('k8s', 'texte', 'kubernetes'), + ('po', 'texte', 'product owner'), + ('pm', 'texte', 'chef de projet'), + ('lead', 'texte', 'technical lead'), + ('architect','texte', 'architecte'), + ('tester', 'texte', 'testeur'), + ('qa', 'texte', 'test'); diff --git a/sql/migration-1.1-vers-1.2.sql b/sql/migration-1.1-vers-1.2.sql new file mode 100644 index 0000000..2127bdb --- /dev/null +++ b/sql/migration-1.1-vers-1.2.sql @@ -0,0 +1,11 @@ +-- Vivier : passage d'une base 1.1 à 1.2 +-- Connexion par identifiant au lieu de l'adresse email, un seul niveau d'accès aux profils. +-- À exécuter une seule fois dans phpMyAdmin. Les adresses email existantes deviennent les identifiants : +-- le gestionnaire peut ensuite les raccourcir dans le menu Comptes. + +SET NAMES utf8mb4; + +ALTER TABLE utilisateurs CHANGE email identifiant VARCHAR(190) NOT NULL; +ALTER TABLE utilisateurs RENAME INDEX email TO identifiant; +UPDATE utilisateurs SET role = 'recruteur' WHERE role = 'lecteur'; +ALTER TABLE utilisateurs MODIFY role ENUM('admin','recruteur') NOT NULL DEFAULT 'recruteur'; diff --git a/sql/migration-1.2-vers-1.3.sql b/sql/migration-1.2-vers-1.3.sql new file mode 100644 index 0000000..a9dad31 --- /dev/null +++ b/sql/migration-1.2-vers-1.3.sql @@ -0,0 +1,22 @@ +-- Vivier : passage d'une base 1.2 à 1.3 +-- Photos des candidats, langue de l'interface par compte, traductions des profils. +-- À exécuter une seule fois dans phpMyAdmin. + +SET NAMES utf8mb4; + +ALTER TABLE utilisateurs ADD COLUMN langue CHAR(2) NOT NULL DEFAULT 'fr' AFTER derniere_connexion; +ALTER TABLE candidats ADD COLUMN photo VARCHAR(100) NULL AFTER texte_recherche; + +CREATE TABLE traductions ( + candidat_id INT UNSIGNED NOT NULL, + langue CHAR(2) NOT NULL, + contenu JSON NOT NULL, + empreinte CHAR(40) CHARACTER SET ascii NOT NULL, + origine ENUM('automatique','fichier') NOT NULL, + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (candidat_id, langue), + CONSTRAINT fk_trad_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_trad_utilisateur FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/sql/migration-1.3-vers-1.4.sql b/sql/migration-1.3-vers-1.4.sql new file mode 100644 index 0000000..1bf3d31 --- /dev/null +++ b/sql/migration-1.3-vers-1.4.sql @@ -0,0 +1,141 @@ +-- Vivier : passage d'une base 1.3 à 1.4 +-- Un seul état « Entretiens en cours », archivage, suivi des entretiens, relances, besoins clients, +-- champs commerciaux, consentement, import en lot, sauvegardes. +-- À exécuter une seule fois dans phpMyAdmin. + +SET NAMES utf8mb4; + +-- Les deux anciens états d'entretien deviennent « entretiens » +ALTER TABLE candidats MODIFY statut ENUM('recu','en_cours','entretien_1_valide','entretien_2_valide','entretiens', + 'non_retenu','valide','embauche') NOT NULL DEFAULT 'recu'; +UPDATE candidats SET statut = 'entretiens' WHERE statut IN ('entretien_1_valide', 'entretien_2_valide'); +ALTER TABLE candidats MODIFY statut ENUM('recu','en_cours','entretiens','non_retenu','valide','embauche') NOT NULL DEFAULT 'recu'; + +ALTER TABLE candidats + ADD COLUMN disponible_le DATE NULL AFTER disponibilite, + ADD COLUMN tjm SMALLINT UNSIGNED NULL AFTER disponible_le, + ADD COLUMN salaire_souhaite INT UNSIGNED NULL AFTER tjm, + ADD COLUMN mobilite VARCHAR(200) NULL AFTER salaire_souhaite, + ADD COLUMN teletravail ENUM('non','partiel','complet') NULL AFTER mobilite, + ADD COLUMN archive_le DATETIME NULL AFTER motif_refus, + ADD COLUMN archive_par INT UNSIGNED NULL AFTER archive_le, + ADD COLUMN consentement_le DATE NULL AFTER conserver_jusqu_au, + ADD COLUMN consentement_note VARCHAR(200) NULL AFTER consentement_le, + ADD KEY idx_archive (archive_le), + ADD CONSTRAINT fk_candidat_archive_par FOREIGN KEY (archive_par) REFERENCES utilisateurs(id); + +-- Le journal accepte de nouvelles actions sans changer la table à chaque version +ALTER TABLE historique MODIFY action VARCHAR(40) NOT NULL; + +-- --------------------------------------------------------------- +-- Besoins clients : les missions à pourvoir, et les profils proposés pour chacune +-- --------------------------------------------------------------- +CREATE TABLE besoins ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + client VARCHAR(200) NOT NULL, + intitule VARCHAR(200) NOT NULL, + description TEXT NULL, + technologies VARCHAR(1000) NULL, -- liste séparée par des virgules + seniorite_min ENUM('junior','intermediaire','confirme','expert') NULL, + langues VARCHAR(300) NULL, -- liste séparée par des virgules + lieu VARCHAR(150) NULL, + teletravail ENUM('non','partiel','complet') NULL, + tjm_max SMALLINT UNSIGNED NULL, -- budget journalier du client, en euros + date_debut DATE NULL, + statut ENUM('ouvert','pourvu','ferme') NOT NULL DEFAULT 'ouvert', + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + modifie_le DATETIME NULL, + KEY idx_besoin_statut (statut), + CONSTRAINT fk_besoin_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE besoin_candidat ( + besoin_id INT UNSIGNED NOT NULL, + candidat_id INT UNSIGNED NOT NULL, + etat ENUM('propose','presente','retenu','ecarte') NOT NULL DEFAULT 'propose', + note VARCHAR(500) NULL, + ajoute_par INT UNSIGNED NOT NULL, + ajoute_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (besoin_id, candidat_id), + KEY idx_bc_candidat (candidat_id), + CONSTRAINT fk_bc_besoin FOREIGN KEY (besoin_id) REFERENCES besoins(id) ON DELETE CASCADE, + CONSTRAINT fk_bc_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_bc_utilisateur FOREIGN KEY (ajoute_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Entretiens : un candidat peut en passer plusieurs pendant le statut « Entretiens en cours » +-- --------------------------------------------------------------- +CREATE TABLE entretiens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + date_entretien DATETIME NOT NULL, + type ENUM('telephone','visio','presentiel','technique','client') NOT NULL, + interlocuteur VARCHAR(150) NULL, -- qui a mené l'entretien + besoin_id INT UNSIGNED NULL, -- besoin client concerné, s'il y en a un + avis ENUM('favorable','reserve','defavorable') NULL, + compte_rendu TEXT NULL, + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_entretien_candidat (candidat_id, date_entretien), + KEY idx_entretien_date (date_entretien), + CONSTRAINT fk_entretien_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_entretien_besoin FOREIGN KEY (besoin_id) REFERENCES besoins(id) ON DELETE SET NULL, + CONSTRAINT fk_entretien_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Relances : une chose à faire pour un candidat, à une date donnée +-- --------------------------------------------------------------- +CREATE TABLE relances ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + echeance DATE NOT NULL, + objet VARCHAR(200) NOT NULL, + fait_le DATETIME NULL, + fait_par INT UNSIGNED NULL, + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_relance_echeance (fait_le, echeance), + KEY idx_relance_candidat (candidat_id), + CONSTRAINT fk_relance_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_relance_fait_par FOREIGN KEY (fait_par) REFERENCES utilisateurs(id), + CONSTRAINT fk_relance_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Import en lot : un CV par ligne, en attente d'analyse ou de relecture. +-- La ligne disparaît quand le profil est enregistré. +-- --------------------------------------------------------------- +CREATE TABLE imports_lot ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jeton CHAR(32) CHARACTER SET ascii NOT NULL, -- nom du dossier temporaire + nom_fichier VARCHAR(255) NOT NULL, + etat ENUM('en_attente','pret','erreur') NOT NULL DEFAULT 'en_attente', + erreur VARCHAR(1000) NULL, + candidat VARCHAR(200) NULL, -- nom lu dans le profil, une fois l'analyse faite + meta JSON NULL, -- fichiers rangés dans le dossier temporaire + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_lot_jeton (jeton), + CONSTRAINT fk_lot_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Sauvegardes de la base : une ligne par exécution, réussie ou non +-- --------------------------------------------------------------- +CREATE TABLE sauvegardes ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + origine ENUM('planifiee','manuelle') NOT NULL, + reussie TINYINT(1) NOT NULL DEFAULT 0, + fichier VARCHAR(100) NULL, -- nom du zip dans stockage/sauvegardes, NULL s'il a été supprimé + taille_octets INT UNSIGNED NULL, + profils INT UNSIGNED NULL, -- nombre de profils dans la sauvegarde + envoi ENUM('envoye','sans_piece','echec','non_demande') NOT NULL DEFAULT 'non_demande', + detail VARCHAR(500) NULL, + cree_par INT UNSIGNED NULL, -- NULL : tâche planifiée + KEY idx_sauvegarde_date (cree_le), + CONSTRAINT fk_sauvegarde_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/migration-1.4-vers-1.5.sql b/sql/migration-1.4-vers-1.5.sql new file mode 100644 index 0000000..b1a1f7a --- /dev/null +++ b/sql/migration-1.4-vers-1.5.sql @@ -0,0 +1,36 @@ +-- Vivier : passage d'une base 1.4 à 1.5 +-- Corbeille des profils (30 jours), demandes de compte à valider, compte du gestionnaire. +-- À exécuter une seule fois dans phpMyAdmin, après une sauvegarde de la base (onglet Exporter). + +SET NAMES utf8mb4; + +-- --------------------------------------------------------------- +-- Corbeille : un profil supprimé reste 30 jours, puis il est effacé avec ses fichiers +-- --------------------------------------------------------------- +ALTER TABLE candidats + ADD COLUMN supprime_le DATETIME NULL AFTER archive_par, + ADD COLUMN supprime_par INT UNSIGNED NULL AFTER supprime_le, + ADD KEY idx_supprime (supprime_le), + ADD CONSTRAINT fk_candidat_supprime_par FOREIGN KEY (supprime_par) REFERENCES utilisateurs(id); + +-- --------------------------------------------------------------- +-- Demandes de compte : la personne s'inscrit, le gestionnaire accepte ou refuse. +-- Un compte en attente a actif = 0 et en_attente = 1 : il ne peut pas se connecter. +-- --------------------------------------------------------------- +ALTER TABLE utilisateurs + ADD COLUMN en_attente TINYINT(1) NOT NULL DEFAULT 0 AFTER actif; + +-- --------------------------------------------------------------- +-- Compte du gestionnaire : Yann PILLA, identifiant ypilla. +-- La colonne mot_de_passe contient une empreinte, pas le mot de passe. +-- Si l'identifiant existe déjà, son mot de passe et son rôle sont mis à jour. +-- --------------------------------------------------------------- +UPDATE utilisateurs + SET nom_affiche = 'Yann PILLA', role = 'admin', actif = 1, en_attente = 0, + mot_de_passe = '$2y$12$ENLcXMSR6WlGLW06M5tgtOtGYw5E9o.BgoPlpImStFrk2MWJVEUE.' + WHERE identifiant = 'ypilla'; + +INSERT INTO utilisateurs (identifiant, nom_affiche, mot_de_passe, role, actif, en_attente) +SELECT 'ypilla', 'Yann PILLA', '$2y$12$ENLcXMSR6WlGLW06M5tgtOtGYw5E9o.BgoPlpImStFrk2MWJVEUE.', 'admin', 1, 0 + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM utilisateurs WHERE identifiant = 'ypilla') AS deja); diff --git a/sql/migration-1.5-vers-1.6.sql b/sql/migration-1.5-vers-1.6.sql new file mode 100644 index 0000000..2e13e2c --- /dev/null +++ b/sql/migration-1.5-vers-1.6.sql @@ -0,0 +1,9 @@ +-- Vivier : passage d'une base 1.5 à 1.6 +-- Noms de famille en majuscules. Aucune table ni colonne ne change. +-- À exécuter une seule fois dans phpMyAdmin, après une sauvegarde de la base (onglet Exporter). +-- Ensuite, dans Vivier : menu Gestion, Comptes, « Réindexer tous les profils ». + +SET NAMES utf8mb4; + +-- Profils existants : « Tonzar » devient « TONZAR ». Les nouveaux profils sont convertis à l'import. +UPDATE candidats SET nom = UPPER(TRIM(nom)); diff --git a/sql/migration-1.6-vers-1.7.sql b/sql/migration-1.6-vers-1.7.sql new file mode 100644 index 0000000..e197438 --- /dev/null +++ b/sql/migration-1.6-vers-1.7.sql @@ -0,0 +1,5 @@ +-- Vivier : migration 1.6 vers 1.7 +-- Les noms de famille passent en capitales, comme Vivier les enregistre désormais à l'import et à la modification. +-- Le prénom ne change pas. À passer une fois, dans phpMyAdmin, onglet SQL. + +UPDATE candidats SET nom = UPPER(nom) WHERE nom <> UPPER(nom) COLLATE utf8mb4_bin; diff --git a/sql/migration-1.7-vers-1.8.sql b/sql/migration-1.7-vers-1.8.sql new file mode 100644 index 0000000..caa3d12 --- /dev/null +++ b/sql/migration-1.7-vers-1.8.sql @@ -0,0 +1,149 @@ +-- Vivier : migration 1.7 vers 1.8 +-- À passer une fois, dans phpMyAdmin, base de Vivier, onglet SQL. Faire d'abord une sauvegarde (onglet Exporter). +-- +-- Contenu : +-- 1. Sécurité des accès : double authentification, invitations, sessions, journal des connexions. +-- 2. Profils : disponibilité en liste fixe, nationalités, permis de travail, champs du CV Europass. +-- 3. Traduction des profils dans la langue de l'interface : file d'attente. +-- 4. Reprise des disponibilités déjà saisies. +-- +-- Après la migration, chaque compte active la double authentification à sa prochaine connexion. + +SET NAMES utf8mb4; + +-- --------------------------------------------------------------- +-- 1. Comptes : double authentification, version de session, mot de passe à changer +-- --------------------------------------------------------------- +ALTER TABLE utilisateurs + ADD COLUMN totp_secret VARCHAR(255) NULL AFTER mot_de_passe, -- clé de l'application d'authentification, chiffrée + ADD COLUMN totp_active_le DATETIME NULL AFTER totp_secret, -- NULL : double authentification à activer + ADD COLUMN totp_dernier_pas BIGINT NULL AFTER totp_active_le, -- dernier code accepté : un code ne sert qu'une fois + ADD COLUMN codes_secours TEXT NULL AFTER totp_dernier_pas, -- empreintes des codes de secours restants (JSON) + ADD COLUMN session_version INT UNSIGNED NOT NULL DEFAULT 1 AFTER codes_secours, -- +1 ferme toutes les sessions du compte + ADD COLUMN changer_mot_de_passe TINYINT(1) NOT NULL DEFAULT 0 AFTER session_version; -- mot de passe donné par le gestionnaire + +-- Invitations : un lien à usage unique, valable 48 heures, remplace la page publique de demande de compte +CREATE TABLE IF NOT EXISTS invitations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jeton CHAR(64) CHARACTER SET ascii NOT NULL, -- empreinte SHA-256 du jeton : le lien lui-même n'est jamais enregistré + nom_affiche VARCHAR(120) NULL, -- nom proposé par le gestionnaire, modifiable par la personne + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expire_le DATETIME NOT NULL, + utilise_le DATETIME NULL, + annule_le DATETIME NULL, + utilisateur_id INT UNSIGNED NULL, -- compte créé avec ce lien + UNIQUE KEY uq_invitation_jeton (jeton), + CONSTRAINT fk_invitation_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id), + CONSTRAINT fk_invitation_utilisateur FOREIGN KEY (utilisateur_id) REFERENCES utilisateurs(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Journal des connexions : chaque essai, réussi ou non. Gardé 12 mois. +CREATE TABLE IF NOT EXISTS connexions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + moment DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + utilisateur_id INT UNSIGNED NULL, + identifiant VARCHAR(190) NOT NULL, + ip VARCHAR(45) NOT NULL, + reseau VARCHAR(45) NOT NULL, -- réseau de l'adresse (IPv4 /24, IPv6 /48) : sert à repérer un lieu nouveau + navigateur VARCHAR(255) NOT NULL DEFAULT '', + resultat VARCHAR(20) NOT NULL, -- reussie, mot_de_passe, code, bloquee, desactive, attente + KEY idx_connexion_moment (moment), + KEY idx_connexion_compte (utilisateur_id, reseau), + CONSTRAINT fk_connexion_utilisateur FOREIGN KEY (utilisateur_id) REFERENCES utilisateurs(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- 2. Profils +-- --------------------------------------------------------------- +ALTER TABLE candidats + ADD COLUMN disponibilite_code ENUM('immediate','1_mois','2_mois','3_mois','date','a_preciser') NULL AFTER disponibilite, + ADD COLUMN nationalites VARCHAR(60) NULL AFTER pays, -- codes ISO 3166-1 séparés par des virgules : FR,MA + ADD COLUMN hors_ue TINYINT(1) NULL AFTER nationalites, -- 0 : au moins une nationalité UE, EEE ou suisse ; 1 : aucune ; NULL : inconnu + ADD COLUMN permis_travail VARCHAR(300) NULL AFTER hors_ue, -- statut du permis de travail au Luxembourg, texte libre + ADD COLUMN permis_expire_le DATE NULL AFTER permis_travail, -- fin de validité du titre + ADD COLUMN benevolat TEXT NULL AFTER encadrement, -- une activité par ligne (CV Europass) + ADD COLUMN permis_conduire VARCHAR(100) NULL AFTER benevolat, -- catégories : B, A1 + ADD KEY idx_hors_ue (hors_ue), + ADD KEY idx_permis_expire (permis_expire_le); + +ALTER TABLE experiences + ADD COLUMN projet VARCHAR(200) NULL AFTER client, + ADD COLUMN mission TEXT NULL AFTER projet, + ADD COLUMN charge VARCHAR(300) NULL AFTER mission; -- charge en jours-homme, avec sa répartition + +ALTER TABLE formations + ADD COLUMN date_debut VARCHAR(7) CHARACTER SET ascii NULL AFTER etablissement, -- AAAA-MM ou AAAA + ADD COLUMN date_fin VARCHAR(7) CHARACTER SET ascii NULL AFTER date_debut, + ADD COLUMN lieu VARCHAR(150) NULL AFTER date_fin; + +-- Niveaux de la grille européenne (CECRL), quand le CV les donne +ALTER TABLE langues + ADD COLUMN ecoute ENUM('A1','A2','B1','B2','C1','C2') NULL AFTER niveau, + ADD COLUMN lecture ENUM('A1','A2','B1','B2','C1','C2') NULL AFTER ecoute, + ADD COLUMN production_orale ENUM('A1','A2','B1','B2','C1','C2') NULL AFTER lecture, + ADD COLUMN interaction_orale ENUM('A1','A2','B1','B2','C1','C2') NULL AFTER production_orale, + ADD COLUMN ecriture ENUM('A1','A2','B1','B2','C1','C2') NULL AFTER interaction_orale; + +-- Relance créée par Vivier (renouvellement du titre de séjour) : Vivier la met à jour quand la date change +ALTER TABLE relances + ADD COLUMN auto VARCHAR(20) NULL AFTER objet, + ADD KEY idx_relance_auto (candidat_id, auto); + +-- --------------------------------------------------------------- +-- 3. Traductions à faire : profils à traduire dans les langues de l'interface +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS traductions_a_faire ( + candidat_id INT UNSIGNED NOT NULL, + langue CHAR(2) NOT NULL, + ajoute_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + essais TINYINT UNSIGNED NOT NULL DEFAULT 0, + erreur VARCHAR(500) NULL, + verrou_le DATETIME NULL, -- traduction en cours : un seul poste la fait + PRIMARY KEY (candidat_id, langue), + CONSTRAINT fk_afaire_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- 4. Reprise des disponibilités +-- Une date déjà saisie par IT Club devient « Date précise ». +-- Le texte du CV est classé par règles simples ; le reste passe en « À préciser ». +-- Le texte d'origine reste en note sous la valeur, pour être corrigé à la main. +-- « Sous N mois » reçoit une date estimée : date de l'import + N mois. +-- --------------------------------------------------------------- +UPDATE candidats SET disponibilite_code = 'date' + WHERE disponibilite_code IS NULL AND disponible_le IS NOT NULL; + +UPDATE candidats + SET disponible_le = STR_TO_DATE(REGEXP_SUBSTR(disponibilite, '[0-9]{2}/[0-9]{2}/[0-9]{4}'), '%d/%m/%Y'), + disponibilite_code = 'date' + WHERE disponibilite_code IS NULL AND disponibilite REGEXP '[0-9]{2}/[0-9]{2}/[0-9]{4}'; + +UPDATE candidats + SET disponible_le = STR_TO_DATE(REGEXP_SUBSTR(disponibilite, '[0-9]{4}-[0-9]{2}-[0-9]{2}'), '%Y-%m-%d'), + disponibilite_code = 'date' + WHERE disponibilite_code IS NULL AND disponibilite REGEXP '[0-9]{4}-[0-9]{2}-[0-9]{2}'; + +UPDATE candidats SET disponibilite_code = 'immediate' + WHERE disponibilite_code IS NULL + AND LOWER(disponibilite) REGEXP 'imm.{1,2}diat|tout de suite|sans pr.{1,2}avis|available now|right away|sofort|^ *disponible *\\.? *$'; + +UPDATE candidats SET disponibilite_code = '1_mois', disponible_le = DATE_ADD(DATE(cree_le), INTERVAL 1 MONTH) + WHERE disponibilite_code IS NULL + AND LOWER(disponibilite) REGEXP '(^|[^0-9])(1|un|one) (mois|month)|4 semaines|4 weeks|30 jours|30 days|1 monat'; + +UPDATE candidats SET disponibilite_code = '2_mois', disponible_le = DATE_ADD(DATE(cree_le), INTERVAL 2 MONTH) + WHERE disponibilite_code IS NULL + AND LOWER(disponibilite) REGEXP '(^|[^0-9])(2|deux|two) (mois|months)|8 semaines|8 weeks|60 jours|60 days|2 monate'; + +UPDATE candidats SET disponibilite_code = '3_mois', disponible_le = DATE_ADD(DATE(cree_le), INTERVAL 3 MONTH) + WHERE disponibilite_code IS NULL + AND LOWER(disponibilite) REGEXP '(^|[^0-9])(3|trois|three) (mois|months)|12 semaines|12 weeks|90 jours|90 days|3 monate'; + +UPDATE candidats SET disponibilite_code = 'a_preciser' + WHERE disponibilite_code IS NULL AND disponibilite IS NOT NULL AND TRIM(disponibilite) <> ''; + +-- --------------------------------------------------------------- +-- Les demandes de compte encore en attente restent visibles dans Comptes, pour être acceptées ou refusées. +-- Les nouvelles personnes rejoignent Vivier par un lien d'invitation. +-- --------------------------------------------------------------- diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..6161f08 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,481 @@ +-- Outil de suivi des CV IT Club +-- Schéma MySQL / MariaDB (InnoDB, utf8mb4) +-- Version 1.8 de la base, format de profil cv-profil.schema.json 1.2 +-- Base déjà créée : exécuter à la place les fichiers migration-*.sql, dans l'ordre. + +SET NAMES utf8mb4; + +-- --------------------------------------------------------------- +-- Comptes et rôles +-- --------------------------------------------------------------- +CREATE TABLE utilisateurs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + identifiant VARCHAR(190) NOT NULL, -- nom de connexion, choisi par le gestionnaire + nom_affiche VARCHAR(120) NOT NULL, + mot_de_passe VARCHAR(255) NOT NULL, -- password_hash() de PHP + totp_secret VARCHAR(255) NULL, -- clé de l'application d'authentification, chiffrée + totp_active_le DATETIME NULL, -- NULL : double authentification à activer + totp_dernier_pas BIGINT NULL, -- dernier code accepté : un code ne sert qu'une fois + codes_secours TEXT NULL, -- empreintes des codes de secours restants (JSON) + session_version INT UNSIGNED NOT NULL DEFAULT 1, -- +1 ferme toutes les sessions du compte + changer_mot_de_passe TINYINT(1) NOT NULL DEFAULT 0, -- mot de passe donné par le gestionnaire : à changer + role ENUM('admin','recruteur') NOT NULL DEFAULT 'recruteur', -- admin : gère aussi les comptes + actif TINYINT(1) NOT NULL DEFAULT 1, + en_attente TINYINT(1) NOT NULL DEFAULT 0, -- demande de compte à valider par le gestionnaire (actif = 0) + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + derniere_connexion DATETIME NULL, + langue CHAR(2) NOT NULL DEFAULT 'fr', -- langue de l'interface pour ce compte + UNIQUE KEY identifiant (identifiant) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Candidats +-- --------------------------------------------------------------- +CREATE TABLE candidats ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + prenom VARCHAR(100) NOT NULL, + nom VARCHAR(100) NOT NULL, + email VARCHAR(190) NULL, + telephone VARCHAR(40) NULL, + rue VARCHAR(200) NULL, + code_postal VARCHAR(20) NULL, + ville VARCHAR(100) NULL, + pays VARCHAR(100) NULL, + nationalites VARCHAR(60) NULL, -- codes ISO 3166-1 séparés par des virgules : FR,MA + hors_ue TINYINT(1) NULL, -- 0 : au moins une nationalité UE, EEE ou suisse ; 1 : aucune ; NULL : inconnu + permis_travail VARCHAR(300) NULL, -- statut du permis de travail au Luxembourg, texte libre + permis_expire_le DATE NULL, -- fin de validité du titre + titre VARCHAR(200) NULL, + resume TEXT NULL, + disponibilite VARCHAR(200) NULL, -- disponibilité telle que le CV l'écrit, gardée en note + disponibilite_code ENUM('immediate','1_mois','2_mois','3_mois','date','a_preciser') NULL, + disponible_le DATE NULL, -- date de disponibilité, quand elle est connue + tjm SMALLINT UNSIGNED NULL, -- taux journalier souhaité, en euros + salaire_souhaite INT UNSIGNED NULL, -- salaire annuel brut souhaité, en euros + mobilite VARCHAR(200) NULL, -- zones acceptées, déplacements + teletravail ENUM('non','partiel','complet') NULL, + niveau_etudes VARCHAR(20) NULL, -- bac, bac+2, bac+3, bac+4, bac+5, doctorat, autre + encadrement TINYINT(1) NULL, -- a encadré une équipe : 1, 0 ou inconnu + benevolat TEXT NULL, -- une activité par ligne (CV Europass) + permis_conduire VARCHAR(100) NULL, -- catégories : B, A1 + + annees_experience DECIMAL(4,1) NOT NULL DEFAULT 0, -- recalculé par PHP à l'import + seniorite ENUM('junior','intermediaire','confirme','expert') NOT NULL, + + statut ENUM('recu','en_cours','entretiens','non_retenu','valide','embauche') NOT NULL DEFAULT 'recu', + motif_refus VARCHAR(500) NULL, -- obligatoire si statut = non_retenu + archive_le DATETIME NULL, -- profil archivé : sorti du Kanban, gardé dans la base + archive_par INT UNSIGNED NULL, + supprime_le DATETIME NULL, -- profil à la corbeille : effacé pour de bon 30 jours après + supprime_par INT UNSIGNED NULL, + position_kanban INT NOT NULL DEFAULT 0, -- ordre dans la colonne + + json_source JSON NOT NULL, -- JSON importé, conservé tel quel + schema_version VARCHAR(10) NOT NULL, + texte_recherche MEDIUMTEXT NOT NULL, -- texte complet, pour la recherche de phrases exactes + + photo VARCHAR(100) NULL, -- chemin interne de la photo, pour reconnaître le candidat + conserver_jusqu_au DATE NULL, -- échéance de suppression (RGPD) + consentement_le DATE NULL, -- date de l'accord du candidat pour la conservation + consentement_note VARCHAR(200) NULL, -- comment l'accord a été recueilli + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + modifie_par INT UNSIGNED NULL, + modifie_le DATETIME NULL, + + UNIQUE KEY uq_candidat_email (email), -- détection des doublons + KEY idx_statut (statut, position_kanban), + KEY idx_seniorite (seniorite), + KEY fk_candidat_cree_par (cree_par), + KEY fk_candidat_modifie_par (modifie_par), + KEY idx_archive (archive_le), + KEY fk_candidat_archive_par (archive_par), + KEY idx_supprime (supprime_le), + KEY idx_hors_ue (hors_ue), + KEY idx_permis_expire (permis_expire_le), + KEY fk_candidat_supprime_par (supprime_par), + FULLTEXT KEY ft_recherche (texte_recherche), + CONSTRAINT fk_candidat_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id), + CONSTRAINT fk_candidat_modifie_par FOREIGN KEY (modifie_par) REFERENCES utilisateurs(id), + CONSTRAINT fk_candidat_archive_par FOREIGN KEY (archive_par) REFERENCES utilisateurs(id), + CONSTRAINT fk_candidat_supprime_par FOREIGN KEY (supprime_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE liens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + type ENUM('linkedin','github','portfolio','site','autre') NOT NULL, + url VARCHAR(500) NOT NULL, + CONSTRAINT fk_lien_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE experiences ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + poste VARCHAR(200) NOT NULL, + employeur VARCHAR(200) NULL, + client VARCHAR(200) NULL, + projet VARCHAR(200) NULL, + mission TEXT NULL, + charge VARCHAR(300) NULL, -- charge en jours-homme, avec sa répartition + lieu VARCHAR(150) NULL, + type ENUM('emploi','mission','alternance','stage','independant') NOT NULL, + date_debut DATE NOT NULL, -- 1er du mois (ou 1er janvier si mois inconnu) + date_fin DATE NULL, -- NULL = en cours + description TEXT NULL, + technologies VARCHAR(1000) NULL, -- liste séparée par des virgules, pour l'affichage + KEY idx_exp_candidat (candidat_id, date_debut), + CONSTRAINT fk_exp_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE formations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + diplome VARCHAR(250) NOT NULL, + etablissement VARCHAR(250) NULL, + date_debut VARCHAR(7) CHARACTER SET ascii NULL, -- AAAA-MM ou AAAA + date_fin VARCHAR(7) CHARACTER SET ascii NULL, + lieu VARCHAR(150) NULL, + annee_fin SMALLINT UNSIGNED NULL, + CONSTRAINT fk_formation_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE certifications ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + nom VARCHAR(250) NOT NULL, + organisme VARCHAR(250) NULL, + annee SMALLINT UNSIGNED NULL, + CONSTRAINT fk_certif_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Référentiel des technologies, partagé entre candidats (filtres, statistiques) +CREATE TABLE technologies ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + nom VARCHAR(100) NOT NULL UNIQUE, + categorie ENUM('langage','framework','base_de_donnees','outil','cloud','methode','autre') NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE candidat_technologie ( + candidat_id INT UNSIGNED NOT NULL, + technologie_id INT UNSIGNED NOT NULL, + annees DECIMAL(4,1) NULL, + PRIMARY KEY (candidat_id, technologie_id), + CONSTRAINT fk_ct_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_ct_techno FOREIGN KEY (technologie_id) REFERENCES technologies(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE langues ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + langue VARCHAR(60) NOT NULL, + niveau ENUM('A1','A2','B1','B2','C1','C2','natif') NULL, + -- Grille européenne (CECRL), quand le CV la donne + ecoute ENUM('A1','A2','B1','B2','C1','C2') NULL, + lecture ENUM('A1','A2','B1','B2','C1','C2') NULL, + production_orale ENUM('A1','A2','B1','B2','C1','C2') NULL, + interaction_orale ENUM('A1','A2','B1','B2','C1','C2') NULL, + ecriture ENUM('A1','A2','B1','B2','C1','C2') NULL, + CONSTRAINT fk_langue_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Documents attachés (CV d'origine, rapport, lettres, diplômes) +-- Les fichiers sont stockés hors de la racine web ; la base garde le chemin. +-- --------------------------------------------------------------- +CREATE TABLE documents ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + type ENUM('cv_origine','rapport','cv_itclub','cv_anonyme','autre') NOT NULL, + nom_fichier VARCHAR(255) NOT NULL, -- nom affiché + chemin VARCHAR(500) NOT NULL, -- chemin interne, nom aléatoire + type_mime VARCHAR(100) NOT NULL, + taille_octets INT UNSIGNED NOT NULL, + ajoute_par INT UNSIGNED NOT NULL, + ajoute_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_doc_candidat (candidat_id), + CONSTRAINT fk_doc_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_doc_utilisateur FOREIGN KEY (ajoute_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Commentaires +-- --------------------------------------------------------------- +CREATE TABLE commentaires ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + auteur_id INT UNSIGNED NOT NULL, + texte TEXT NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + modifie_le DATETIME NULL, + KEY idx_com_candidat (candidat_id, cree_le), + CONSTRAINT fk_com_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_com_auteur FOREIGN KEY (auteur_id) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Journal d'audit : qui a modifié quoi et quand +-- Pas de clé étrangère vers candidats : la trace survit à une suppression. +-- --------------------------------------------------------------- +CREATE TABLE historique ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NULL, + utilisateur_id INT UNSIGNED NOT NULL, + action VARCHAR(40) NOT NULL, -- code de l'action, voir ACTIONS_JOURNAL + champ VARCHAR(100) NULL, + ancienne_valeur TEXT NULL, + nouvelle_valeur TEXT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_hist_candidat (candidat_id, cree_le), + KEY idx_hist_utilisateur (utilisateur_id, cree_le), + CONSTRAINT fk_hist_utilisateur FOREIGN KEY (utilisateur_id) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Mots-clés et métadonnées de recherche +-- Fournis par l'analyse (manuel = 0) ou ajoutés à la main sur la fiche (manuel = 1). +-- --------------------------------------------------------------- +CREATE TABLE mots_cles ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + type ENUM('role','specialite','secteur','mot_cle') NOT NULL, + mot VARCHAR(80) NOT NULL, + manuel TINYINT(1) NOT NULL DEFAULT 0, + UNIQUE KEY uq_mot (candidat_id, type, mot), + CONSTRAINT fk_mot_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Index de recherche : un terme normalisé par ligne, avec son poids. +-- Reconstruit par PHP à chaque import ou modification d'un profil. +-- --------------------------------------------------------------- +CREATE TABLE index_recherche ( + candidat_id INT UNSIGNED NOT NULL, + terme VARCHAR(60) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + poids SMALLINT UNSIGNED NOT NULL, + PRIMARY KEY (candidat_id, terme), + KEY idx_terme (terme), + CONSTRAINT fk_index_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Traductions du contenu d'un profil, pour générer le CV dans une autre langue. +-- L'empreinte désigne la version du profil traduite : si le profil change, la traduction est périmée. +-- --------------------------------------------------------------- +CREATE TABLE traductions ( + candidat_id INT UNSIGNED NOT NULL, + langue CHAR(2) NOT NULL, + contenu JSON NOT NULL, + empreinte CHAR(40) CHARACTER SET ascii NOT NULL, + origine ENUM('automatique','fichier') NOT NULL, + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (candidat_id, langue), + CONSTRAINT fk_trad_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_trad_utilisateur FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Profils à traduire dans les langues de l'interface : le navigateur d'un compte connecté les traite un par un +CREATE TABLE traductions_a_faire ( + candidat_id INT UNSIGNED NOT NULL, + langue CHAR(2) NOT NULL, + ajoute_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + essais TINYINT UNSIGNED NOT NULL DEFAULT 0, + erreur VARCHAR(500) NULL, + verrou_le DATETIME NULL, -- traduction en cours : un seul poste la fait + PRIMARY KEY (candidat_id, langue), + CONSTRAINT fk_afaire_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Besoins clients : les missions à pourvoir, et les profils proposés pour chacune +-- --------------------------------------------------------------- +CREATE TABLE besoins ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + client VARCHAR(200) NOT NULL, + intitule VARCHAR(200) NOT NULL, + description TEXT NULL, + technologies VARCHAR(1000) NULL, -- liste séparée par des virgules + seniorite_min ENUM('junior','intermediaire','confirme','expert') NULL, + langues VARCHAR(300) NULL, -- liste séparée par des virgules + lieu VARCHAR(150) NULL, + teletravail ENUM('non','partiel','complet') NULL, + tjm_max SMALLINT UNSIGNED NULL, -- budget journalier du client, en euros + date_debut DATE NULL, + statut ENUM('ouvert','pourvu','ferme') NOT NULL DEFAULT 'ouvert', + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + modifie_le DATETIME NULL, + KEY idx_besoin_statut (statut), + CONSTRAINT fk_besoin_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE besoin_candidat ( + besoin_id INT UNSIGNED NOT NULL, + candidat_id INT UNSIGNED NOT NULL, + etat ENUM('propose','presente','retenu','ecarte') NOT NULL DEFAULT 'propose', + note VARCHAR(500) NULL, + ajoute_par INT UNSIGNED NOT NULL, + ajoute_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (besoin_id, candidat_id), + KEY idx_bc_candidat (candidat_id), + CONSTRAINT fk_bc_besoin FOREIGN KEY (besoin_id) REFERENCES besoins(id) ON DELETE CASCADE, + CONSTRAINT fk_bc_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_bc_utilisateur FOREIGN KEY (ajoute_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Entretiens : un candidat peut en passer plusieurs pendant le statut « Entretiens en cours » +-- --------------------------------------------------------------- +CREATE TABLE entretiens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + date_entretien DATETIME NOT NULL, + type ENUM('telephone','visio','presentiel','technique','client') NOT NULL, + interlocuteur VARCHAR(150) NULL, -- qui a mené l'entretien + besoin_id INT UNSIGNED NULL, -- besoin client concerné, s'il y en a un + avis ENUM('favorable','reserve','defavorable') NULL, + compte_rendu TEXT NULL, + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_entretien_candidat (candidat_id, date_entretien), + KEY idx_entretien_date (date_entretien), + CONSTRAINT fk_entretien_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_entretien_besoin FOREIGN KEY (besoin_id) REFERENCES besoins(id) ON DELETE SET NULL, + CONSTRAINT fk_entretien_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Relances : une chose à faire pour un candidat, à une date donnée +-- --------------------------------------------------------------- +CREATE TABLE relances ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + candidat_id INT UNSIGNED NOT NULL, + echeance DATE NOT NULL, + objet VARCHAR(200) NOT NULL, + auto VARCHAR(20) NULL, -- relance créée par Vivier (permis) : mise à jour quand la date change + fait_le DATETIME NULL, + fait_par INT UNSIGNED NULL, + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_relance_echeance (fait_le, echeance), + KEY idx_relance_candidat (candidat_id), + KEY idx_relance_auto (candidat_id, auto), + CONSTRAINT fk_relance_candidat FOREIGN KEY (candidat_id) REFERENCES candidats(id) ON DELETE CASCADE, + CONSTRAINT fk_relance_fait_par FOREIGN KEY (fait_par) REFERENCES utilisateurs(id), + CONSTRAINT fk_relance_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Import en lot : un CV par ligne, en attente d'analyse ou de relecture. +-- La ligne disparaît quand le profil est enregistré. +-- --------------------------------------------------------------- +CREATE TABLE imports_lot ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jeton CHAR(32) CHARACTER SET ascii NOT NULL, -- nom du dossier temporaire + nom_fichier VARCHAR(255) NOT NULL, + etat ENUM('en_attente','pret','erreur') NOT NULL DEFAULT 'en_attente', + erreur VARCHAR(1000) NULL, + candidat VARCHAR(200) NULL, -- nom lu dans le profil, une fois l'analyse faite + meta JSON NULL, -- fichiers rangés dans le dossier temporaire + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_lot_jeton (jeton), + CONSTRAINT fk_lot_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Sauvegardes de la base : une ligne par exécution, réussie ou non +-- --------------------------------------------------------------- +CREATE TABLE sauvegardes ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + origine ENUM('planifiee','manuelle') NOT NULL, + reussie TINYINT(1) NOT NULL DEFAULT 0, + fichier VARCHAR(100) NULL, -- nom du zip dans stockage/sauvegardes, NULL s'il a été supprimé + taille_octets INT UNSIGNED NULL, + profils INT UNSIGNED NULL, -- nombre de profils dans la sauvegarde + envoi ENUM('envoye','sans_piece','echec','non_demande') NOT NULL DEFAULT 'non_demande', + detail VARCHAR(500) NULL, + cree_par INT UNSIGNED NULL, -- NULL : tâche planifiée + KEY idx_sauvegarde_date (cree_le), + CONSTRAINT fk_sauvegarde_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Échecs de connexion récents : blocage temporaire (voir src/securite.php) +-- --------------------------------------------------------------- +CREATE TABLE tentatives_connexion ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + cle VARCHAR(190) NOT NULL, + moment DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_cle (cle, moment) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Invitations : un lien à usage unique, valable 48 heures +CREATE TABLE invitations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jeton CHAR(64) CHARACTER SET ascii NOT NULL, -- empreinte SHA-256 du jeton : le lien lui-même n'est jamais enregistré + nom_affiche VARCHAR(120) NULL, + cree_par INT UNSIGNED NOT NULL, + cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expire_le DATETIME NOT NULL, + utilise_le DATETIME NULL, + annule_le DATETIME NULL, + utilisateur_id INT UNSIGNED NULL, + UNIQUE KEY uq_invitation_jeton (jeton), + CONSTRAINT fk_invitation_cree_par FOREIGN KEY (cree_par) REFERENCES utilisateurs(id), + CONSTRAINT fk_invitation_utilisateur FOREIGN KEY (utilisateur_id) REFERENCES utilisateurs(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Journal des connexions : chaque essai, réussi ou non. Gardé 12 mois. +CREATE TABLE connexions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + moment DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + utilisateur_id INT UNSIGNED NULL, + identifiant VARCHAR(190) NOT NULL, + ip VARCHAR(45) NOT NULL, + reseau VARCHAR(45) NOT NULL, -- réseau de l'adresse (IPv4 /24, IPv6 /48) + navigateur VARCHAR(255) NOT NULL DEFAULT '', + resultat VARCHAR(20) NOT NULL, -- reussie, mot_de_passe, code, bloquee, desactive, attente + KEY idx_connexion_moment (moment), + KEY idx_connexion_compte (utilisateur_id, reseau), + CONSTRAINT fk_connexion_utilisateur FOREIGN KEY (utilisateur_id) REFERENCES utilisateurs(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- +-- Synonymes de recherche : traduit un mot tapé en filtre ou en termes +-- --------------------------------------------------------------- +CREATE TABLE synonymes_recherche ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + terme VARCHAR(100) NOT NULL, + cible_type ENUM('seniorite','texte') NOT NULL, + cible VARCHAR(100) NOT NULL, + KEY idx_terme (terme) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO synonymes_recherche (terme, cible_type, cible) VALUES + ('senior', 'seniorite', 'confirme'), + ('senior', 'seniorite', 'expert'), + ('sénior', 'seniorite', 'confirme'), + ('sénior', 'seniorite', 'expert'), + ('junior', 'seniorite', 'junior'), + ('confirmé','seniorite', 'confirme'), + ('expert', 'seniorite', 'expert'), + ('intermédiaire', 'seniorite', 'intermediaire'), + ('dev', 'texte', 'développeur'), + ('developer','texte', 'développeur'), + ('développeuse','texte', 'développeur'), + ('fullstack','texte', 'full-stack'), + ('backend', 'texte', 'back-end'), + ('frontend','texte', 'front-end'), + ('js', 'texte', 'javascript'), + ('ts', 'texte', 'typescript'), + ('postgres','texte', 'postgresql'), + ('k8s', 'texte', 'kubernetes'), + ('po', 'texte', 'product owner'), + ('pm', 'texte', 'chef de projet'), + ('lead', 'texte', 'technical lead'), + ('architect','texte', 'architecte'), + ('tester', 'texte', 'testeur'), + ('qa', 'texte', 'test'); diff --git a/src/.htaccess b/src/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/src/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/src/Claude.php b/src/Claude.php new file mode 100644 index 0000000..2fe6bd0 --- /dev/null +++ b/src/Claude.php @@ -0,0 +1,167 @@ +\nle profil JSON, sans balise de code\n\n\nle rapport en Markdown\n\n\n" + . "Si le document n'est pas un CV ou est illisible, écris à la place : la raison en une phrase"; + + $consigne = "Analyse ce CV. Date du jour : $aujourdhui. Nom du fichier reçu : " . $fichier['nom']; + $texte = texte_du_cv($fichier['tmp'], $fichier['ext']); + $contenu = $texte === null + ? [ + ['type' => 'document', 'source' => ['type' => 'base64', 'media_type' => 'application/pdf', 'data' => base64_encode((string) file_get_contents($fichier['tmp']))]], + ['type' => 'text', 'text' => $consigne], + ] + : [['type' => 'text', 'text' => $consigne . "\n\n\n" . $texte . "\n"]]; + + $modele = (string) ($c['modele'] ?? 'claude-sonnet-5-5'); + $reponse = self::appeler($c, [ + 'model' => $modele, + 'max_tokens' => (int) ($c['max_tokens'] ?? 12000), + 'system' => $systeme, + 'messages' => [['role' => 'user', 'content' => $contenu]], + ]); + + $sortie = ''; + foreach ($reponse['content'] ?? [] as $bloc) { + if (($bloc['type'] ?? '') === 'text') { + $sortie .= $bloc['text']; + } + } + if (($reponse['stop_reason'] ?? '') === 'max_tokens') { + throw new RuntimeException(t('L\'analyse a été coupée avant la fin : le CV est trop long. Passez par l\'import de fichiers déjà analysés.')); + } + if (preg_match('#(.*?)#su', $sortie, $m)) { + throw new RuntimeException(t('Analyse impossible : %s', trim($m[1]))); + } + if (!preg_match('#(.*?)#su', $sortie, $m)) { + throw new RuntimeException(t('La réponse de l\'analyse ne contient pas de profil. Réessayez.')); + } + $json = trim(preg_replace('/^```(?:json)?\s*|\s*```$/u', '', trim($m[1])) ?? $m[1]); + $profil = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException(t('Le profil renvoyé par l\'analyse n\'est pas un JSON valide. Réessayez.')); + } + // Ces deux champs décrivent l'import : le serveur les connaît mieux que le modèle + if (is_array($profil)) { + $profil['source'] = ['fichier_origine' => $fichier['nom'], 'date_analyse' => $aujourdhui, 'langue_cv' => $profil['source']['langue_cv'] ?? null]; + } + + return [ + 'profil' => $profil, + 'rapport' => preg_match('#(.*?)#su', $sortie, $r) ? trim($r[1]) : '', + 'modele' => $modele, + ]; + } + + /** + * Traduit les textes libres d'un profil. + * @param array $paquet voir paquet_traduction() + * @return mixed le paquet traduit, à contrôler par enregistrer_traduction() + * @throws RuntimeException + */ + public static function traduire(array $paquet, string $source, string $cible): mixed + { + $c = (array) config('claude'); + $noms = ['fr' => 'français', 'en' => 'anglais', 'de' => 'allemand']; + $systeme = "Tu traduis le contenu d'un CV pour IT Club Luxembourg, société de conseil IT. " + . 'Langue du CV : ' . ($noms[$source] ?? $source) . '. Langue d\'arrivée : ' . ($noms[$cible] ?? $cible) . ".\n" + . "Les textes peuvent mêler deux langues : les intitulés suivent la langue du CV, la synthèse et les points d'attention sont rédigés en français. " + . "Traduis chaque texte dans la langue d'arrivée. Un texte déjà dans la langue d'arrivée reste tel quel.\n\n" + . "Règles :\n" + . "- Tu reçois un objet JSON. Renvoie le même objet, avec les mêmes clés, et chaque texte traduit. Ne traduis jamais une clé.\n" + . "- Dans « metiers », les clés sont des métiers : renvoie chaque métier traduit en valeur, au masculin singulier, en minuscules.\n" + . "- Garde tels quels les noms propres : technologies, produits, entreprises, clients, intitulés officiels de certifications (AWS Certified Developer, PRINCE2) et sigles. " + . "Traduis une certification ou une formation décrite en mots courants (« Leadership et coordination d'équipes »).\n" + . "- Traduis les intitulés de poste par l'intitulé d'usage dans la langue d'arrivée.\n" + . "- Garde les retours à la ligne. N'ajoute rien, ne résume pas, ne corrige pas le fond.\n" + . "- Registre professionnel, phrases sans sujet nommé comme dans l'original.\n\n" + . "Tu réponds à un programme. Écris uniquement : l'objet JSON, sans balise de code"; + + $reponse = self::appeler($c, [ + 'model' => (string) ($c['modele'] ?? 'claude-sonnet-5-5'), + 'max_tokens' => (int) ($c['max_tokens'] ?? 12000), + 'system' => $systeme, + 'messages' => [['role' => 'user', 'content' => [['type' => 'text', 'text' => json_encode($paquet, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)]]]], + ]); + $sortie = ''; + foreach ($reponse['content'] ?? [] as $bloc) { + if (($bloc['type'] ?? '') === 'text') { + $sortie .= $bloc['text']; + } + } + if (($reponse['stop_reason'] ?? '') === 'max_tokens' || !preg_match('#(.*?)#su', $sortie, $m)) { + throw new RuntimeException(t('La traduction automatique n\'a pas abouti. Réessayez, ou passez par le fichier de traduction.')); + } + return json_decode(trim(preg_replace('/^```(?:json)?\s*|\s*```$/u', '', trim($m[1])) ?? $m[1]), true); + } + + private static function appeler(array $c, array $corps): array + { + $delai = (int) ($c['delai'] ?? 240); + @set_time_limit($delai + 30); + + $ch = curl_init((string) ($c['url'] ?? 'https://api.anthropic.com/v1/messages')); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => $delai, + CURLOPT_HTTPHEADER => [ + 'content-type: application/json', + 'anthropic-version: 2023-06-01', + 'x-api-key: ' . trim((string) $c['cle_api']), + ], + CURLOPT_POSTFIELDS => json_encode($corps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + $brut = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $erreur = curl_errno($ch); + curl_close($ch); + + if ($brut === false) { + throw new RuntimeException($erreur === CURLE_OPERATION_TIMEDOUT + ? t('L\'analyse a dépassé le délai de %d secondes. Réessayez, ou passez par l\'import de fichiers déjà analysés.', $delai) + : t('Le service d\'analyse est injoignable. Vérifiez la connexion du serveur.')); + } + $reponse = json_decode((string) $brut, true); + if ($code !== 200 || !is_array($reponse)) { + $detail = is_array($reponse) ? (string) ($reponse['error']['message'] ?? '') : ''; + throw new RuntimeException(match (true) { + $code === 401, $code === 403 => t('La clé d\'API Claude est refusée. Vérifiez-la dans config/config.php.'), + $code === 429 => t('Trop de demandes ou crédit épuisé côté API Claude. Réessayez plus tard.'), + $code >= 500 => t('Le service d\'analyse est momentanément indisponible. Réessayez dans quelques minutes.'), + default => t('L\'analyse a échoué (code %d). %s', $code, mb_strimwidth($detail, 0, 200, '…')), + }); + } + return $reponse; + } +} diff --git a/src/ExportEuropass.php b/src/ExportEuropass.php new file mode 100644 index 0000000..b9b3f48 --- /dev/null +++ b/src/ExportEuropass.php @@ -0,0 +1,451 @@ +open($tmp, ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException(t('Le fichier Word n\'a pas pu être créé.')); + } + $avecPhoto = $photo !== null && is_file($photo); + $zip->addFromString('[Content_Types].xml', self::types()); + $zip->addFromString('_rels/.rels', self::relsRacine()); + $zip->addFromString('docProps/core.xml', self::proprietes($d)); + $zip->addFromString('word/document.xml', self::document($d)); + $zip->addFromString('word/header1.xml', self::entete($d, $logo, $avecPhoto ? $photo : null)); + $zip->addFromString('word/header2.xml', '' . self::p('') . ''); + $zip->addFromString('word/_rels/header1.xml.rels', self::relsEntete($avecPhoto)); + $zip->addFromString('word/styles.xml', self::styles(['fr' => 'fr-FR', 'en' => 'en-GB', 'de' => 'de-DE'][$d['langue']] ?? 'fr-FR')); + $zip->addFromString('word/settings.xml', self::reglages()); + $zip->addFromString('word/footer1.xml', self::pied()); + $zip->addFromString('word/_rels/document.xml.rels', self::relsDocument()); + $zip->addFromString('word/media/logo.png', (string) file_get_contents($logo)); + if ($avecPhoto) { + $zip->addFromString('word/media/photo.jpg', (string) file_get_contents($photo)); + } + $zip->close(); + $contenu = (string) file_get_contents($tmp); + unlink($tmp); + return $contenu; + } + + // ----------------------------------------------------------------------- + // Briques + // ----------------------------------------------------------------------- + + private static function x(string $texte): string + { + $texte = preg_replace('/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', '', $texte) ?? ''; + return htmlspecialchars($texte, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } + + /** Options : b, i, caps, color, sz (demi-points). */ + private static function r(string $texte, array $o = []): string + { + $p = ''; + if (!empty($o['b'])) { $p .= ''; } + if (!empty($o['i'])) { $p .= ''; } + if (!empty($o['caps'])) { $p .= ''; } + if (isset($o['color'])) { $p .= ''; } + if (isset($o['sz'])) { $p .= ''; } + return '' . ($p !== '' ? '' . $p . '' : '') . '' . self::x($texte) . ''; + } + + /** Options : style, avant, apres, keepNext, retrait (gauche), suspendu, jc, bordureBas [taille, couleur]. */ + private static function p(string $runs, array $o = []): string + { + $p = ''; + if (isset($o['style'])) { $p .= ''; } + if (!empty($o['keepNext'])) { $p .= ''; } + if (isset($o['bordureBas'])) { + $p .= ''; + } + if (isset($o['avant']) || isset($o['apres'])) { + $p .= ''; + } + if (isset($o['retrait'])) { + $p .= ''; + } + if (isset($o['jc'])) { $p .= ''; } + return '' . ($p !== '' ? '' . $p . '' : '') . $runs . ''; + } + + private static function tc(int $largeur, string $contenu, array $o = []): string + { + $p = ''; + if (isset($o['span'])) { $p .= ''; } + if (isset($o['bordures'])) { $p .= '' . $o['bordures'] . ''; } + if (isset($o['fond'])) { $p .= ''; } + if (isset($o['marges'])) { + [$h, $dr, $b, $g] = $o['marges']; + $p .= '' + . ''; + } + $p .= ''; + return '' . $p . '' . $contenu . ''; + } + + private static function tbl(array $colonnes, string $lignes, int $retrait = 0): string + { + $grille = ''; + foreach ($colonnes as $l) { + $grille .= ''; + } + return '' + . '' + . '' + . '' . $grille . '' . $lignes . ''; + } + + private static function filet(string $cote, int $taille, string $couleur): string + { + return ''; + } + + /** Image en ligne. $l et $h en mm. */ + private static function image(string $rid, int $id, string $nom, float $l, float $h): string + { + $cx = (int) round($l * 36000); + $cy = (int) round($h * 36000); + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private static function titre(string $texte): string + { + return self::p(self::r('● ', ['color' => self::BLEU_CLAIR, 'sz' => 16]) . self::r($texte), ['style' => 'TitreEuropass']); + } + + // ----------------------------------------------------------------------- + // Document + // ----------------------------------------------------------------------- + + private static function document(array $d): string + { + $L = $d['libelles']; + $corps = ''; + + if ($d['resume']) { + $corps .= self::titre($L['apropos']); + foreach ($d['resume'] as $l) { + $corps .= self::p(self::r($l)); + } + } + + if ($d['formations']) { + $corps .= self::titre($L['formation']); + foreach ($d['formations'] as $i => $f) { + $dernier = $i === count($d['formations']) - 1; + if (trim($f['dates'] . $f['lieu']) !== '') { + $corps .= self::p(self::r(trim($f['dates'] . ' ' . $f['lieu']), ['color' => self::GRIS_CLAIR, 'sz' => 17]), ['apres' => 20, 'keepNext' => true]); + } + $corps .= self::p( + self::r($f['diplome'], ['b' => true, 'color' => self::GRIS, 'sz' => 20]) + . ($f['etablissement'] !== '' ? self::r(' ' . $f['etablissement'], ['color' => self::GRIS, 'sz' => 20]) : ''), + ['apres' => 120] + ($dernier ? [] : ['bordureBas' => [4, self::FILET]]) + ); + } + } + + if ($d['experiences']) { + $corps .= self::titre($L['experience']); + foreach ($d['experiences'] as $i => $x) { + $dernier = $i === count($d['experiences']) - 1; + if ($x['employeur'] !== '' || $x['lieu'] !== '') { + $corps .= self::p( + self::r($x['employeur'], ['b' => true]) . ($x['employeur'] !== '' && $x['lieu'] !== '' ? self::r(' – ') : '') . self::r($x['lieu']), + ['apres' => 20, 'keepNext' => true] + ); + } + $corps .= self::p( + self::r($x['poste'], ['b' => true, 'color' => self::GRIS, 'sz' => 20]) . self::r(' – ' . $x['dates'], ['color' => self::BLEU, 'sz' => 20]), + ['apres' => 40, 'keepNext' => true] + ); + foreach ($x['details'] as [$libelle, $valeur]) { + $corps .= self::p(self::r($libelle . $d['deux_points'] . ' ', ['b' => true]) . self::r($valeur), ['apres' => 20]); + } + if ($x['realisations']) { + $corps .= self::p(self::r($L['realisations'], ['b' => true]), ['avant' => 40, 'apres' => 20, 'keepNext' => true]); + foreach ($x['realisations'] as $r) { + $corps .= self::p(self::r('•') . '' . self::r($r), ['retrait' => 600, 'suspendu' => 240, 'apres' => 10]); + } + } + $corps .= self::p('', ['apres' => 100] + ($dernier ? [] : ['bordureBas' => [4, self::FILET]])); + } + } + + if ($d['maternelles'] || $d['autres']) { + $corps .= self::titre($L['langues']); + if ($d['maternelles']) { + $corps .= self::p(self::r($L['maternelles'] . ' ') . self::r(implode(' | ', $d['maternelles']), ['b' => true])); + } + if ($d['autres']) { + $corps .= self::p(self::r($L['autres']), ['avant' => 60]); + $corps .= $d['avec_grille'] ? self::grille($d) : self::p(implode(self::r(' '), array_map( + static fn ($l) => self::r($l['langue'], ['b' => true]) . ($l['niveau'] !== '' ? self::r($d['deux_points'] . ' ' . $l['niveau']) : ''), + $d['autres'] + ))); + $corps .= self::p(self::r($L['niveaux'], ['i' => true, 'color' => self::GRIS_CLAIR, 'sz' => 16]), ['avant' => 40]); + } + } + + if ($d['competences']) { + $corps .= self::titre($L['competences']); + foreach ($d['competences'] as $groupe => $liste) { + $corps .= self::p(self::r($groupe, ['b' => true, 'color' => self::GRIS_CLAIR]), ['avant' => 80, 'apres' => 20, 'keepNext' => true]); + $corps .= self::p(self::r(implode(' | ', $liste))); + } + } + + if ($d['benevolat']) { + $corps .= self::titre($L['benevolat']); + foreach ($d['benevolat'] as $b) { + $corps .= self::p(self::r($b, ['b' => true])); + } + } + + if ($d['permis']) { + $corps .= self::titre($L['permis']); + foreach ($d['permis'] as $p) { + $corps .= self::p(self::r($L['permis_ligne'] . ' ', ['b' => true]) . self::r($p)); + } + } + + // Première page : le bandeau est dans son en-tête ; les pages suivantes ont un en-tête vide et une marge haute + $section = '' + . '' + . '' + . '' + . '' + . '' + . ''; + + return '' + . '' . $corps . $section . ''; + } + + /** En-tête de la première page : bandeau bleu de bord à bord, avec photo, nom, titre, coordonnées et logo. */ + private static function entete(array $d, string $logo, ?string $photo): string + { + $bleu = ['fond' => self::BLEU]; + [$lpx, $hpx] = getimagesize($logo) ?: [700, 142]; + $logoL = 34.0; + $logoH = round($logoL * $hpx / $lpx, 1); + $lLogo = 2400; + $lPhoto = $photo ? 2000 : 0; + $lTexte = self::PAGE_L - 2 * self::MARGE - $lLogo - $lPhoto; + + $identite = self::p(self::r($d['nom'], ['b' => true, 'color' => 'FFFFFF', 'sz' => 34]), ['apres' => 20]); + if ($d['titre'] !== '') { + $identite .= self::p(self::r($d['titre'], ['color' => 'DBE9F4', 'sz' => 21]), ['apres' => 60]); + } + if ($d['coordonnees']) { + $runs = ''; + foreach ($d['coordonnees'] as $i => $k) { + $runs .= ($i ? self::r(' | ', ['color' => '6E95B8', 'sz' => 17]) : '') + . self::r($k['libelle'] . $d['deux_points'] . ' ', ['b' => true, 'color' => 'FFFFFF', 'sz' => 17]) + . self::r($k['valeur'], ['color' => 'FFFFFF', 'sz' => 17]); + } + $identite .= self::p($runs, ['apres' => 0]); + } + + $vide = self::p('', ['apres' => 0]); + $cellules = self::tc(self::MARGE, $vide, $bleu); + if ($photo) { + $cellules .= self::tc($lPhoto, self::p(self::image('rId2', 2, 'Photo', 28, 28), ['apres' => 0]), $bleu + ['vAlign' => 'center']); + } + $cellules .= self::tc($lTexte, $identite, $bleu + ['vAlign' => 'center', 'marges' => [0, 200, 0, 0]]) + . self::tc($lLogo, self::p(self::image('rId1', 1, 'europass', $logoL, $logoH), ['apres' => 0, 'jc' => 'right']), $bleu + ['vAlign' => 'top']) + . self::tc(self::MARGE, $vide, $bleu); + $colonnes = array_values(array_filter([self::MARGE, $lPhoto, $lTexte, $lLogo, self::MARGE])); + $lignes = '' . self::tc(self::PAGE_L, $vide, $bleu + ['span' => count($colonnes)]) . '' + . '' . $cellules . '' + . '' . self::tc(self::PAGE_L, $vide, $bleu + ['span' => count($colonnes)]) . ''; + return '' + . self::tbl($colonnes, $lignes, -self::MARGE) . self::p('', ['apres' => 0]) . ''; + } + + /** Grille européenne des langues. */ + private static function grille(array $d): string + { + $L = $d['libelles']; + $lLangue = 1900; + $lCase = intdiv(self::TEXTE_L - $lLangue, 5); + $colonnes = [$lLangue, $lCase, $lCase, $lCase, $lCase, $lCase]; + $centre = static fn (string $runs): string => self::p($runs, ['jc' => 'center', 'apres' => 0]); + $haut = self::filet('top', 6, self::BLEU_CLAIR); + $bas = self::filet('bottom', 6, self::BLEU_CLAIR); + $marges = ['marges' => [60, 40, 60, 40]]; + + $lignes = '' . self::tc($lLangue, self::p(''), $marges) + . self::tc(2 * $lCase, $centre(self::r(mb_strtoupper($L['comprehension']), ['b' => true, 'sz' => 17])), $marges + ['span' => 2, 'bordures' => $bas]) + . self::tc(2 * $lCase, $centre(self::r(mb_strtoupper($L['expression']), ['b' => true, 'sz' => 17])), $marges + ['span' => 2, 'bordures' => $bas]) + . self::tc($lCase, $centre(self::r(mb_strtoupper($L['ecrit']), ['b' => true, 'sz' => 17])), $marges + ['bordures' => $bas]) + . ''; + $sous = self::tc($lLangue, self::p(''), $marges); + foreach (array_slice($L['grille'], 0, 4) as $g) { + $sous .= self::tc($lCase, $centre(self::r($g, ['sz' => 16])), $marges); + } + $lignes .= '' . $sous . self::tc($lCase, self::p(''), $marges) . ''; + + foreach ($d['autres'] as $i => $l) { + $fond = $i % 2 === 0 ? ['fond' => self::FOND_LIGNE] : []; + $bord = ['bordures' => $haut . ($i === count($d['autres']) - 1 ? $bas : '')]; + $cellules = self::tc($lLangue, self::p(self::r($l['langue'], ['b' => true, 'sz' => 17]), ['apres' => 0]), $marges + $fond + $bord + ['vAlign' => 'center']); + if ($l['grille']) { + foreach ($l['grille'] as $niveau) { + $cellules .= self::tc($lCase, $centre(self::r($niveau ?: '–', ['sz' => 17])), $marges + $fond + $bord + ['vAlign' => 'center']); + } + } else { + $cellules .= self::tc(5 * $lCase, $centre(self::r($l['niveau'] !== '' ? $L['niveau'] . ' ' . $l['niveau'] : '–', ['sz' => 17])), $marges + $fond + $bord + ['span' => 5, 'vAlign' => 'center']); + } + $lignes .= '' . $cellules . ''; + } + return self::tbl($colonnes, $lignes); + } + + private static function pied(): string + { + $petit = ['color' => self::GRIS_CLAIR, 'sz' => 14]; + $champ = static fn (string $nom): string => '' . self::r('1', $petit) . ''; + return '' + . '' + . self::p($champ('PAGE') . self::r(' / ', $petit) . $champ('NUMPAGES'), ['jc' => 'right', 'apres' => 0]) + . ''; + } + + // ----------------------------------------------------------------------- + // Fichiers fixes + // ----------------------------------------------------------------------- + + private static function styles(string $langue): string + { + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private static function reglages(): string + { + return '' + . '' + . '' + . '' + . ''; + } + + private static function types(): string + { + $wml = 'application/vnd.openxmlformats-officedocument.wordprocessingml.'; + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private static function relsRacine(): string + { + return '' + . '' + . '' + . '' + . ''; + } + + private static function relsDocument(): string + { + $t = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/'; + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private static function relsEntete(bool $photo): string + { + $t = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/'; + return '' + . '' + . '' + . ($photo ? '' : '') + . ''; + } + + private static function proprietes(array $d): string + { + $date = gmdate('Y-m-d\TH:i:s\Z'); + return '' + . '' + . '' . self::x($d['fichier']) . 'IT Club' + . '' . $date . '' + . '' . $date . '' + . ''; + } +} diff --git a/src/ExportWord.php b/src/ExportWord.php new file mode 100644 index 0000000..2fbca0d --- /dev/null +++ b/src/ExportWord.php @@ -0,0 +1,460 @@ +open($tmp, ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException(t('Le fichier Word n\'a pas pu être créé.')); + } + $zip->addFromString('[Content_Types].xml', self::types()); + $zip->addFromString('_rels/.rels', self::relsRacine()); + $zip->addFromString('docProps/core.xml', self::proprietes($d)); + $zip->addFromString('word/document.xml', self::document($d)); + $zip->addFromString('word/styles.xml', self::styles(['fr' => 'fr-FR', 'en' => 'en-GB', 'de' => 'de-DE'][$d['langue'] ?? 'fr'] ?? 'fr-FR')); + $zip->addFromString('word/settings.xml', self::reglages()); + $zip->addFromString('word/header1.xml', self::bandeau($d, $logoPng)); + $zip->addFromString('word/footer1.xml', self::pied($d)); + $zip->addFromString('word/_rels/document.xml.rels', self::relsDocument()); + $zip->addFromString('word/_rels/header1.xml.rels', self::relsBandeau()); + $zip->addFromString('word/media/logo.png', (string) file_get_contents($logoPng)); + $zip->close(); + + $contenu = (string) file_get_contents($tmp); + unlink($tmp); + return $contenu; + } + + // ----------------------------------------------------------------------- + // Briques + // ----------------------------------------------------------------------- + + private static function x(string $texte): string + { + // Retire les caractères de contrôle interdits en XML 1.0 + $texte = preg_replace('/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', '', $texte) ?? ''; + return htmlspecialchars($texte, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } + + /** + * Un segment de texte. + * Options : b, i, caps, font, color, sz (demi-points), spacing (vingtièmes de point). + */ + private static function r(string $texte, array $o = []): string + { + $p = ''; + if (isset($o['font'])) { + $p .= ''; + } + if (!empty($o['b'])) { $p .= ''; } + if (!empty($o['i'])) { $p .= ''; } + if (!empty($o['caps'])) { $p .= ''; } + if (isset($o['color'])) { $p .= ''; } + if (isset($o['spacing'])) { $p .= ''; } + if (isset($o['sz'])) { $p .= ''; } + + return '' . ($p !== '' ? '' . $p . '' : '') + . '' . self::x($texte) . ''; + } + + /** + * Un paragraphe. + * Options : style, keepNext, avant, apres (twips), ligne (hauteur exacte), bordureBas [taille, couleur], + * tabs (XML), jc. + */ + private static function p(string $runs, array $o = []): string + { + $p = ''; + if (isset($o['style'])) { $p .= ''; } + if (!empty($o['keepNext'])) { $p .= ''; } + if (isset($o['bordureBas'])) { + $p .= ''; + } + if (isset($o['tabs'])) { $p .= '' . $o['tabs'] . ''; } + if (isset($o['avant']) || isset($o['apres']) || isset($o['ligne'])) { + $p .= ''; + } + if (isset($o['jc'])) { $p .= ''; } + + return '' . ($p !== '' ? '' . $p . '' : '') . $runs . ''; + } + + /** Paragraphe vide de hauteur minimale : séparateur entre deux tableaux. */ + private static function espace(int $hauteur = 20): string + { + return self::p('', ['avant' => 0, 'apres' => 0, 'ligne' => $hauteur]); + } + + /** + * Une cellule. + * Options : span, fond, bordures (XML), marges [haut, droite, bas, gauche], vAlign. + */ + private static function tc(int $largeur, string $contenu, array $o = []): string + { + $p = ''; + if (isset($o['span'])) { $p .= ''; } + if (isset($o['bordures'])) { $p .= '' . $o['bordures'] . ''; } + if (isset($o['fond'])) { $p .= ''; } + if (isset($o['marges'])) { + [$h, $dr, $b, $g] = $o['marges']; + $p .= '' + . ''; + } + if (isset($o['vAlign'])) { $p .= ''; } + + return '' . $p . '' . $contenu . ''; + } + + /** Options de ligne : hauteur (exacte), insecable. */ + private static function tr(string $cellules, array $o = []): string + { + $p = ''; + if (!empty($o['insecable'])) { $p .= ''; } + if (isset($o['hauteur'])) { $p .= ''; } + return '' . ($p !== '' ? '' . $p . '' : '') . $cellules . ''; + } + + /** Tableau sans bordure, à largeurs de colonnes fixes. */ + private static function tbl(array $colonnes, string $lignes, int $retrait = 0): string + { + $grille = ''; + foreach ($colonnes as $l) { + $grille .= ''; + } + return '' + . '' + . '' + . '' . $grille . '' . $lignes . ''; + } + + private static function filet(string $cote, int $taille, string $couleur): string + { + return ''; + } + + private static function titre(string $texte, int $avant = 150): string + { + return self::p(self::r($texte), ['style' => 'Titre2', 'avant' => $avant]); + } + + // ----------------------------------------------------------------------- + // Corps du document + // ----------------------------------------------------------------------- + + private static function document(array $d): string + { + $corps = ''; + + // Repères : une ligne « LIBELLÉ valeur », filet gris dessous + $runs = ''; + foreach ($d['reperes'] as $libelle => $valeur) { + // Espaces insécables : le libellé ne se sépare pas de sa valeur en fin de ligne + $insecable = mb_strlen($valeur) <= 30 ? str_replace(' ', "\u{00A0}", $valeur) : $valeur; + $runs .= ($runs !== '' ? self::r(' ') : '') + . self::r($libelle . "\u{00A0}", ['caps' => true, 'color' => self::GRIS, 'sz' => 14, 'spacing' => 12]) + . self::r($insecable, ['b' => true]); + } + $corps .= self::p($runs, ['avant' => 120, 'apres' => 60, 'bordureBas' => [4, self::TRAIT, 6]]); + + if ($d['resume']) { + $corps .= self::titre($d['libelles']['synthese']); + foreach ($d['resume'] as $l) { + $corps .= self::p(self::r($l)); + } + } + + if ($d['competences']) { + $corps .= self::titre($d['libelles']['competences']); + $lignes = ''; + foreach ($d['competences'] as $libelle => $technos) { + $runs = ''; + foreach ($technos as $i => $t) { + $runs .= ($i ? self::r(' · ') : '') . self::r($t['nom']) + . ($t['annees'] !== '' ? self::r("\u{00A0}" . $t['annees'], ['color' => self::OR_TEXTE, 'sz' => 15]) : ''); + } + $lignes .= self::tr( + self::tc(1960, self::p(self::r($libelle, ['caps' => true, 'color' => self::GRIS, 'sz' => 15, 'spacing' => 10]), ['apres' => 20])) + . self::tc(self::TEXTE_L - 1960, self::p($runs, ['apres' => 20])) + ); + } + $corps .= self::tbl([1960, self::TEXTE_L - 1960], $lignes); + } + + if ($d['experiences']) { + $corps .= self::titre($d['libelles']['experiences']); + $lignes = ''; + foreach ($d['experiences'] as $i => $x) { + $bord = $i ? ['bordures' => self::filet('top', 4, self::TRAIT)] : []; + $marges = ['marges' => [$i ? 70 : 0, 0, 70, 0]]; + + $periode = self::p( + self::r($x['debut'], ['b' => true, 'color' => self::OR_TEXTE, 'sz' => 16]) + . '' + . self::r($x['fin'], ['b' => true, 'color' => self::OR_TEXTE, 'sz' => 16]), + ['apres' => 0] + ); + + $contenu = self::p( + self::r($x['poste'], ['b' => true, 'sz' => 20]) + . ($x['contexte'] !== '' ? self::r(' ' . $x['contexte'], ['color' => self::GRIS, 'sz' => 18]) : ''), + ['apres' => 30, 'keepNext' => true] + ); + if ($x['cadre'] !== '') { + $contenu .= self::p(self::r($x['cadre'], ['color' => self::GRIS, 'sz' => 17]), ['apres' => 20]); + } + if ($x['mission'] !== '') { + $contenu .= self::p(self::r($x['mission'], ['i' => true]), ['apres' => 30]); + } + foreach ($x['lignes'] as $l) { + $contenu .= self::p(self::r($l), ['apres' => 20]); + } + if ($x['environnement'] !== '') { + $contenu .= self::p( + self::r($d['libelles']['environnement'] . ' ', ['b' => true, 'sz' => 17]) . self::r($x['environnement'], ['sz' => 17, 'color' => '333333']), + ['avant' => 20, 'apres' => 0] + ); + } + + $lignes .= self::tr( + self::tc(1190, $periode, $bord + $marges) . self::tc(self::TEXTE_L - 1190, $contenu, $bord + $marges), + ['insecable' => true] + ); + } + $corps .= self::tbl([1190, self::TEXTE_L - 1190], $lignes); + } + + // Formation et certifications côte à côte + $blocs = array_filter([$d['libelles']['formation'] => $d['formations'], $d['libelles']['certifications'] => $d['certifications']]); + if ($blocs) { + $corps .= self::espace(60); + $largeur = intdiv(self::TEXTE_L, count($blocs)); + $cellules = ''; + $n = 0; + foreach ($blocs as $intitule => $liste) { + $contenu = self::titre($intitule, 60); + foreach ($liste as $f) { + $contenu .= self::p( + self::r($f['titre'], ['b' => true]) . ($f['detail'] !== '' ? self::r(', ' . $f['detail']) : ''), + ['apres' => 30] + ); + } + $cellules .= self::tc($largeur, $contenu, ['marges' => [0, $n === 0 && count($blocs) > 1 ? 200 : 0, 0, $n > 0 ? 200 : 0]]); + $n++; + } + $corps .= self::tbl(array_fill(0, count($blocs), $largeur), self::tr($cellules, ['insecable' => true])); + } + + // Encadré de contact + $corps .= self::espace(200); + $corps .= self::tbl([self::TEXTE_L], self::tr(self::tc( + self::TEXTE_L, + self::p( + self::r($d['signature'], ['i' => true, 'font' => 'Georgia', 'sz' => 20]) . '' . self::r($d['contact']), + ['avant' => 0, 'apres' => 0, 'tabs' => ''] + ), + ['fond' => self::FOND_CONTACT, 'bordures' => self::filet('left', 24, self::OR), 'marges' => [110, 200, 110, 200]] + ), ['insecable' => true])); + $corps .= self::espace(); + + $section = '' + . '' + . '' + . '' + . '' + . '' + . ''; + + return '' + . '' . $corps . $section . ''; + } + + // ----------------------------------------------------------------------- + // Bandeau noir de première page (en-tête) et pied de page + // ----------------------------------------------------------------------- + + private static function bandeau(array $d, string $logoPng): string + { + // Logo : 36 mm de large, proportions du fichier + [$lpx, $hpx] = getimagesize($logoPng) ?: [1600, 500]; + $cx = 36 * 36000; + $cy = (int) round($cx * $hpx / $lpx); + + $image = '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + + $noir = ['fond' => self::NOIR]; + $lLogo = 2041 + 397; // logo + 7 mm d'espace + $lNom = self::PAGE_L - self::MARGE - $lLogo; + $vide = self::p('', ['avant' => 0, 'apres' => 0, 'ligne' => 20]); + + $identite = self::p(self::r($d['nom'], ['font' => 'Georgia', 'color' => 'FFFFFF', 'sz' => 38]), ['avant' => 0, 'apres' => 0]); + if ($d['titre'] !== '') { + $identite .= self::p(self::r($d['titre'], ['color' => self::OR, 'sz' => 21, 'spacing' => 6]), ['avant' => 40, 'apres' => 0]); + } + + $lignes = self::tr(self::tc(self::PAGE_L, $vide, $noir + ['span' => 3]), ['hauteur' => 369]) + . self::tr( + self::tc(self::MARGE, $vide, $noir) + . self::tc($lLogo, self::p($image, ['avant' => 0, 'apres' => 0]), $noir + ['vAlign' => 'center']) + . self::tc($lNom, $identite, $noir + ['vAlign' => 'center', 'bordures' => self::filet('left', 6, self::OR), 'marges' => [0, self::MARGE, 0, 397]]) + ) + . self::tr(self::tc(self::PAGE_L, $vide, $noir + ['span' => 3, 'bordures' => self::filet('bottom', 24, self::OR)]), ['hauteur' => 340]); + + return '' + . '' + . self::tbl([self::MARGE, $lLogo, $lNom], $lignes, -self::MARGE) + . self::espace() + . ''; + } + + private static function pied(array $d): string + { + $petit = ['color' => self::GRIS, 'sz' => 13]; + $champ = static fn (string $nom): string => '' . self::r('1', $petit) . ''; + + return '' + . '' + . self::p( + '' . self::r($d['mentions'], $petit) . '' + . $champ('PAGE') . self::r(' / ', $petit) . $champ('NUMPAGES'), + [ + 'avant' => 0, 'apres' => 0, + 'tabs' => '', + ] + ) + . ''; + } + + // ----------------------------------------------------------------------- + // Fichiers fixes de l'archive + // ----------------------------------------------------------------------- + + private static function styles(string $langue): string + { + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private static function reglages(): string + { + return '' + . '' + . '' + . '' + . ''; + } + + private static function types(): string + { + $wml = 'application/vnd.openxmlformats-officedocument.wordprocessingml.'; + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private static function relsRacine(): string + { + return '' + . '' + . '' + . '' + . ''; + } + + private static function relsDocument(): string + { + $t = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/'; + return '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private static function relsBandeau(): string + { + return '' + . '' + . '' + . ''; + } + + private static function proprietes(array $d): string + { + $date = gmdate('Y-m-d\TH:i:s\Z'); + return '' + . '' + . '' . self::x($d['fichier']) . 'IT Club' + . '' . $date . '' + . '' . $date . '' + . ''; + } +} diff --git a/src/Import.php b/src/Import.php new file mode 100644 index 0000000..59ea15c --- /dev/null +++ b/src/Import.php @@ -0,0 +1,685 @@ + $n) { + if (!is_string($n) || !isset(PAYS[$n])) { + $err[] = "identite.nationalites[$i] : " . t('code pays ISO à deux lettres attendu (FR, MA).'); + } + } + self::chaineRequise($err, $id, 'prenom', 'identite'); + self::chaineRequise($err, $id, 'nom', 'identite'); + self::chaineOuNull($err, $id, 'telephone', 'identite'); + if (isset($id['email']) && (!is_string($id['email']) || !filter_var($id['email'], FILTER_VALIDATE_EMAIL))) { + $err[] = 'identite.email : ' . t('adresse invalide.'); + } + if (isset($id['adresse'])) { + if (!is_array($id['adresse'])) { + $err[] = 'identite.adresse : ' . t('objet attendu.'); + } else { + self::cles($err, $id['adresse'], 'identite.adresse', [], ['rue', 'code_postal', 'ville', 'pays']); + foreach (['rue', 'code_postal', 'ville', 'pays'] as $k) { + self::chaineOuNull($err, $id['adresse'], $k, 'identite.adresse'); + } + } + } + } elseif (array_key_exists('identite', $d)) { + $err[] = 'identite : ' . t('objet attendu.'); + } + + foreach (['titre', 'resume', 'disponibilite'] as $k) { + self::chaineOuNull($err, $d, $k, 'profil'); + } + + // Disponibilité classée et CV Europass (version 1.2) + if (isset($d['disponibilite_code']) && !in_array($d['disponibilite_code'], self::CODES_DISPONIBILITE, true)) { + $err[] = 'disponibilite_code : ' . t('valeur inconnue (attendu : %s ou null).', implode(', ', self::CODES_DISPONIBILITE)); + } + if (isset($d['disponibilite_date'])) { + $dt = is_string($d['disponibilite_date']) ? DateTimeImmutable::createFromFormat('!Y-m-d', $d['disponibilite_date']) : false; + if (!$dt || $dt->format('Y-m-d') !== $d['disponibilite_date']) { + $err[] = 'disponibilite_date : ' . t('date AAAA-MM-JJ attendue.'); + } + } + if (($d['disponibilite_code'] ?? null) === 'date' && empty($d['disponibilite_date'])) { + $err[] = 'disponibilite_date : ' . t('date obligatoire quand disponibilite_code vaut « date ».'); + } + foreach (['benevolat', 'permis_conduire'] as $k) { + foreach (self::liste($err, $d, $k) as $i => $v) { + if (!is_string($v) || trim($v) === '') { $err[] = "{$k}[$i] : " . t('texte non vide attendu.'); } + } + } + + // Liens + foreach (self::liste($err, $d, 'liens') as $i => $l) { + $ou = "liens[$i]"; + if (!self::estObjet($l)) { $err[] = "$ou : " . t('objet attendu.'); continue; } + self::cles($err, $l, $ou, ['type', 'url'], []); + self::enum($err, $l, 'type', $ou, self::TYPES_LIEN); + $url = $l['url'] ?? null; + if (!is_string($url) || !filter_var($url, FILTER_VALIDATE_URL) + || !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) { + $err[] = "$ou.url : " . t('adresse http(s) attendue.'); + } + } + + // Expériences + foreach (self::liste($err, $d, 'experiences') as $i => $x) { + $ou = "experiences[$i]"; + if (!self::estObjet($x)) { $err[] = "$ou : " . t('objet attendu.'); continue; } + self::cles($err, $x, $ou, ['poste', 'date_debut', 'type'], + ['employeur', 'client', 'lieu', 'date_fin', 'description', 'technologies', 'projet', 'mission', 'charge']); + self::chaineRequise($err, $x, 'poste', $ou); + self::enum($err, $x, 'type', $ou, self::TYPES_EXPERIENCE); + foreach (['employeur', 'client', 'lieu', 'description', 'projet', 'mission', 'charge'] as $k) { + self::chaineOuNull($err, $x, $k, $ou); + } + $deb = $x['date_debut'] ?? null; + $fin = $x['date_fin'] ?? null; + $debOk = is_string($deb) && preg_match(self::DATE_MOIS, $deb); + if (!$debOk && array_key_exists('date_debut', $x)) { + $err[] = "$ou.date_debut : " . t('format AAAA-MM ou AAAA attendu.'); + } + if ($fin !== null) { + if (!is_string($fin) || !preg_match(self::DATE_MOIS, $fin)) { + $err[] = "$ou.date_fin : " . t('format AAAA-MM, AAAA ou null attendu.'); + } elseif ($debOk && self::indiceMois($fin) < self::indiceMois($deb)) { + $err[] = "$ou : " . t('la date de fin précède la date de début.'); + } + } + foreach (self::liste($err, $x, 'technologies', $ou) as $j => $t) { + if (!is_string($t)) { $err[] = "{$ou}.technologies[$j] : " . t('texte attendu.'); } + } + } + + // Formations et certifications + foreach (self::liste($err, $d, 'formations') as $i => $f) { + $ou = "formations[$i]"; + if (!self::estObjet($f)) { $err[] = "$ou : " . t('objet attendu.'); continue; } + self::cles($err, $f, $ou, ['diplome'], ['etablissement', 'annee_fin', 'date_debut', 'date_fin', 'lieu']); + self::chaineRequise($err, $f, 'diplome', $ou); + self::chaineOuNull($err, $f, 'etablissement', $ou); + self::chaineOuNull($err, $f, 'lieu', $ou); + self::annee($err, $f, 'annee_fin', $ou); + foreach (['date_debut', 'date_fin'] as $k) { + if (isset($f[$k]) && (!is_string($f[$k]) || !preg_match(self::DATE_MOIS, $f[$k]))) { + $err[] = "$ou.$k : " . t('format AAAA-MM, AAAA ou null attendu.'); + } + } + } + foreach (self::liste($err, $d, 'certifications') as $i => $c) { + $ou = "certifications[$i]"; + if (!self::estObjet($c)) { $err[] = "$ou : " . t('objet attendu.'); continue; } + self::cles($err, $c, $ou, ['nom'], ['organisme', 'annee']); + self::chaineRequise($err, $c, 'nom', $ou); + self::chaineOuNull($err, $c, 'organisme', $ou); + self::annee($err, $c, 'annee', $ou); + } + + // Technologies + foreach (self::liste($err, $d, 'technologies') as $i => $t) { + $ou = "technologies[$i]"; + if (!self::estObjet($t)) { $err[] = "$ou : " . t('objet attendu.'); continue; } + self::cles($err, $t, $ou, ['nom', 'categorie'], ['annees']); + self::chaineRequise($err, $t, 'nom', $ou); + self::enum($err, $t, 'categorie', $ou, self::CATEGORIES); + if (isset($t['annees']) && (!(is_int($t['annees']) || is_float($t['annees'])) || $t['annees'] < 0)) { + $err[] = "$ou.annees : " . t('nombre positif ou null attendu.'); + } + } + + // Langues + foreach (self::liste($err, $d, 'langues') as $i => $l) { + $ou = "langues[$i]"; + if (!self::estObjet($l)) { $err[] = "$ou : " . t('objet attendu.'); continue; } + self::cles($err, $l, $ou, ['langue'], ['niveau', 'cecrl']); + self::chaineRequise($err, $l, 'langue', $ou); + if (isset($l['niveau']) && !in_array($l['niveau'], self::NIVEAUX_LANGUE, true)) { + $err[] = "$ou.niveau : " . t('valeur inconnue (attendu : %s ou null).', implode(', ', self::NIVEAUX_LANGUE)); + } + if (isset($l['cecrl'])) { + if (!self::estObjet($l['cecrl'])) { + $err[] = "$ou.cecrl : " . t('objet attendu.'); + } else { + self::cles($err, $l['cecrl'], "$ou.cecrl", [], self::GRILLE); + foreach (self::GRILLE as $k) { + if (isset($l['cecrl'][$k]) && !in_array($l['cecrl'][$k], self::CECRL, true)) { + $err[] = "$ou.cecrl.$k : " . t('valeur inconnue (attendu : %s ou null).', implode(', ', self::CECRL)); + } + } + } + } + } + + // Séniorité + $s = $d['seniorite'] ?? null; + if (self::estObjet($s)) { + self::cles($err, $s, 'seniorite', ['annees_experience', 'niveau'], []); + $a = $s['annees_experience'] ?? null; + if (array_key_exists('annees_experience', $s) && (!(is_int($a) || is_float($a)) || $a < 0)) { + $err[] = 'seniorite.annees_experience : ' . t('nombre positif attendu.'); + } + self::enum($err, $s, 'niveau', 'seniorite', self::NIVEAUX); + } elseif (array_key_exists('seniorite', $d)) { + $err[] = 'seniorite : ' . t('objet attendu.'); + } + + // Points d'attention + foreach (self::liste($err, $d, 'points_attention') as $i => $p) { + if (!is_string($p)) { $err[] = "points_attention[$i] : " . t('texte attendu.'); } + } + + // Mots-clés et métadonnées (version 1.1) + self::motsCourts($err, $d, 'mots_cles', '', 60); + if (isset($d['metadonnees'])) { + $m = $d['metadonnees']; + if (!self::estObjet($m)) { + $err[] = 'metadonnees : ' . t('objet attendu.'); + } else { + self::cles($err, $m, 'metadonnees', [], ['roles', 'specialites', 'secteurs', 'niveau_etudes', 'encadrement']); + foreach (['roles', 'specialites', 'secteurs'] as $k) { + self::motsCourts($err, $m, $k, 'metadonnees', 10); + } + if (isset($m['niveau_etudes']) && !in_array($m['niveau_etudes'], self::NIVEAUX_ETUDES, true)) { + $err[] = 'metadonnees.niveau_etudes : ' . t('valeur inconnue (attendu : %s ou null).', implode(', ', self::NIVEAUX_ETUDES)); + } + if (isset($m['encadrement']) && !is_bool($m['encadrement'])) { + $err[] = 'metadonnees.encadrement : ' . t('true, false ou null attendu.'); + } + } + } + + // Source + $src = $d['source'] ?? null; + if (self::estObjet($src)) { + self::cles($err, $src, 'source', ['fichier_origine', 'date_analyse'], ['langue_cv']); + self::chaineRequise($err, $src, 'fichier_origine', 'source'); + self::chaineOuNull($err, $src, 'langue_cv', 'source'); + $da = $src['date_analyse'] ?? null; + if (array_key_exists('date_analyse', $src)) { + $dt = is_string($da) ? DateTimeImmutable::createFromFormat('!Y-m-d', $da) : false; + if (!$dt || $dt->format('Y-m-d') !== $da) { + $err[] = 'source.date_analyse : ' . t('date AAAA-MM-JJ attendue.'); + } + } + } elseif (array_key_exists('source', $d)) { + $err[] = 'source : ' . t('objet attendu.'); + } + + return $err; + } + + private static function estObjet(mixed $v): bool + { + return is_array($v) && ($v === [] || !array_is_list($v)); + } + + private static function cles(array &$err, array $o, string $ou, array $requises, array $optionnelles): void + { + foreach ($requises as $k) { + if (!array_key_exists($k, $o)) { + $err[] = "$ou : " . t('champ obligatoire manquant « %s ».', $k); + } + } + foreach (array_keys($o) as $k) { + if (!in_array($k, $requises, true) && !in_array($k, $optionnelles, true)) { + $err[] = "$ou : " . t('clé inconnue « %s ».', $k); + } + } + } + + private static function chaineRequise(array &$err, array $o, string $k, string $ou): void + { + if (array_key_exists($k, $o) && (!is_string($o[$k]) || trim($o[$k]) === '')) { + $err[] = "$ou.$k : " . t('texte non vide attendu.'); + } + } + + private static function chaineOuNull(array &$err, array $o, string $k, string $ou): void + { + if (isset($o[$k]) && !is_string($o[$k])) { + $err[] = "$ou.$k : " . t('texte ou null attendu.'); + } + } + + private static function enum(array &$err, array $o, string $k, string $ou, array $valeurs): void + { + if (array_key_exists($k, $o) && !in_array($o[$k], $valeurs, true)) { + $err[] = "$ou.$k : " . t('valeur inconnue (attendu : %s).', implode(', ', $valeurs)); + } + } + + private static function annee(array &$err, array $o, string $k, string $ou): void + { + if (isset($o[$k]) && (!is_int($o[$k]) || $o[$k] < 1950 || $o[$k] > 2100)) { + $err[] = "$ou.$k : " . t('année entre 1950 et 2100 ou null attendue.'); + } + } + + /** Liste de textes courts (mots-clés, rôles, secteurs). */ + private static function motsCourts(array &$err, array $o, string $k, string $ou, int $max): void + { + $chemin = ($ou !== '' ? "$ou." : '') . $k; + $liste = self::liste($err, $o, $k, $ou); + if (count($liste) > $max) { + $err[] = "$chemin : " . t('%d éléments au maximum.', $max); + } + foreach ($liste as $i => $v) { + if (!is_string($v) || trim($v) === '' || mb_strlen($v) > 80) { + $err[] = "{$chemin}[$i] : " . t('texte de 1 à 80 caractères attendu.'); + } + } + } + + /** Renvoie la liste à parcourir, ou [] en signalant l'erreur de type. */ + private static function liste(array &$err, array $o, string $k, string $ou = ''): array + { + if (!array_key_exists($k, $o)) { + return []; + } + if (!is_array($o[$k]) || !array_is_list($o[$k])) { + $err[] = ($ou !== '' ? "$ou." : '') . "$k : " . t('liste attendue.'); + return []; + } + return $o[$k]; + } + + // ----------------------------------------------------------------------- + // Séniorité + // ----------------------------------------------------------------------- + + /** Numéro de mois absolu. Un mois manquant vaut janvier. */ + public static function indiceMois(string $date): int + { + $p = explode('-', $date); + return (int) $p[0] * 12 + (int) ($p[1] ?? 1) - 1; + } + + /** + * Mois d'expérience, périodes fusionnées, stages exclus. + * Un poste terminé compte son dernier mois. Un poste en cours compte + * jusqu'au dernier mois complet. + */ + public static function moisExperience(array $experiences, DateTimeImmutable $aujourdhui, ?string $techno = null): int + { + $ceMois = (int) $aujourdhui->format('Y') * 12 + (int) $aujourdhui->format('n') - 1; + $cle = $techno !== null ? mb_strtolower(trim($techno)) : null; + $periodes = []; + + foreach ($experiences as $x) { + if (($x['type'] ?? '') === 'stage') { + continue; + } + if ($cle !== null) { + $citees = array_map(static fn ($t) => mb_strtolower(trim((string) $t)), $x['technologies'] ?? []); + if (!in_array($cle, $citees, true)) { + continue; + } + } + $debut = self::indiceMois($x['date_debut']); + $fin = ($x['date_fin'] ?? null) !== null ? self::indiceMois($x['date_fin']) + 1 : $ceMois; + $fin = min($fin, $ceMois + 1); + if ($fin > $debut) { + $periodes[] = [$debut, $fin]; + } + } + + sort($periodes); + $total = 0; + $courant = null; + foreach ($periodes as [$d, $f]) { + if ($courant === null) { + $courant = [$d, $f]; + } elseif ($d <= $courant[1]) { + $courant[1] = max($courant[1], $f); + } else { + $total += $courant[1] - $courant[0]; + $courant = [$d, $f]; + } + } + if ($courant !== null) { + $total += $courant[1] - $courant[0]; + } + return $total; + } + + public static function annees(int $mois): float + { + return round($mois / 12, 1); + } + + public static function niveau(float $annees): string + { + return match (true) { + $annees < 4 => 'junior', + $annees < 8 => 'intermediaire', + $annees < 12 => 'confirme', + default => 'expert', + }; + } + + /** + * Synthèse affichée sur l'écran de relecture. + * @return array{annees:float,niveau:string,annees_json:float,niveau_json:string,ecart:bool} + */ + public static function seniorite(array $profil, ?DateTimeImmutable $aujourdhui = null): array + { + $aujourdhui ??= new DateTimeImmutable('today'); + $annees = self::annees(self::moisExperience($profil['experiences'], $aujourdhui)); + $niveau = self::niveau($annees); + $anneesJson = (float) $profil['seniorite']['annees_experience']; + $niveauJson = $profil['seniorite']['niveau']; + return [ + 'annees' => $annees, + 'niveau' => $niveau, + 'annees_json' => $anneesJson, + 'niveau_json' => $niveauJson, + 'ecart' => $niveau !== $niveauJson || abs($annees - $anneesJson) > 0.5, + ]; + } + + // ----------------------------------------------------------------------- + // Enregistrement + // ----------------------------------------------------------------------- + + /** Renvoie l'identifiant d'un candidat déjà enregistré avec cet email, ou null. */ + public static function doublon(PDO $pdo, array $profil): ?int + { + $email = $profil['identite']['email'] ?? null; + if ($email === null) { + return null; + } + $req = $pdo->prepare('SELECT id FROM candidats WHERE email = ?'); + $req->execute([mb_strtolower($email)]); + $id = $req->fetchColumn(); + return $id === false ? null : (int) $id; + } + + /** Chiffres d'un numéro de téléphone, sans indicatif : les 9 derniers au plus. Vide si le numéro est trop court pour comparer. */ + public static function cleTelephone(?string $telephone): string + { + $chiffres = preg_replace('/\D+/', '', (string) $telephone) ?? ''; + return strlen($chiffres) < 8 ? '' : substr($chiffres, -9); + } + + /** + * Profils déjà enregistrés qui ressemblent à celui-ci : même email, même téléphone, ou même prénom et nom. + * La comparaison des noms ignore la casse et les accents (classement de la base) et accepte l'ordre inversé. + * @return array + */ + public static function doublons(PDO $pdo, array $profil, int $saufId = 0): array + { + $id = $profil['identite']; + $trouves = []; + $noter = static function (PDOStatement $req, string $motif) use (&$trouves, $saufId): void { + foreach ($req as $l) { + if ((int) $l['id'] === $saufId) { + continue; + } + $trouves[$l['id']] ??= ['id' => (int) $l['id'], 'prenom' => $l['prenom'], 'nom' => $l['nom'], 'titre' => $l['titre'], + 'archive' => $l['archive_le'] !== null, 'corbeille' => $l['supprime_le'] !== null, 'motifs' => []]; + $trouves[$l['id']]['motifs'][] = $motif; + } + }; + // Un profil à la corbeille compte encore : son adresse email reste prise tant qu'il n'est pas effacé + $champs = 'id, prenom, nom, titre, archive_le, supprime_le'; + + if (!empty($id['email'])) { + $req = $pdo->prepare("SELECT $champs FROM candidats WHERE email = ?"); + $req->execute([mb_strtolower($id['email'])]); + $noter($req, 'email'); + } + $tel = self::cleTelephone($id['telephone'] ?? null); + if ($tel !== '') { + $n = strlen($tel); + $req = $pdo->prepare("SELECT $champs FROM candidats WHERE telephone IS NOT NULL AND RIGHT(REGEXP_REPLACE(telephone, '[^0-9]', ''), $n) = ?"); + $req->execute([$tel]); + $noter($req, 'telephone'); + } + $prenom = trim((string) $id['prenom']); + $nom = trim((string) $id['nom']); + $req = $pdo->prepare("SELECT $champs FROM candidats WHERE (prenom = ? AND nom = ?) OR (prenom = ? AND nom = ?)"); + $req->execute([$prenom, $nom, $nom, $prenom]); + $noter($req, 'nom'); + + return array_values($trouves); + } + + /** Colonnes de la table candidats tirées du profil. */ + private static function colonnes(array $profil, DateTimeImmutable $aujourdhui): array + { + $id = $profil['identite']; + $adr = $id['adresse'] ?? []; + $meta = $profil['metadonnees'] ?? []; + $sen = self::seniorite($profil, $aujourdhui); + + $nationalites = codes_nationalites($id['nationalites'] ?? []); + $dispo = isset($profil['disponibilite_code']) + ? ['code' => $profil['disponibilite_code'], 'date' => $profil['disponibilite_code'] === 'date' + ? ($profil['disponibilite_date'] ?? null) + : date_disponibilite($profil['disponibilite_code'], null, $aujourdhui)] + : classer_disponibilite($profil['disponibilite'] ?? null, $aujourdhui); + + return [ + 'prenom' => self::couper($id['prenom'], 100), + 'nom' => self::couper(mb_strtoupper(trim((string) $id['nom'])), 100), // nom de famille en capitales + 'email' => isset($id['email']) ? mb_strtolower($id['email']) : null, + 'telephone' => self::couper($id['telephone'] ?? null, 40), + 'rue' => self::couper($adr['rue'] ?? null, 200), + 'code_postal' => self::couper($adr['code_postal'] ?? null, 20), + 'ville' => self::couper($adr['ville'] ?? null, 100), + 'pays' => self::couper($adr['pays'] ?? null, 100), + 'nationalites' => $nationalites ? implode(',', $nationalites) : null, + 'hors_ue' => hors_ue($nationalites), + 'titre' => self::couper($profil['titre'] ?? null, 200), + 'resume' => $profil['resume'] ?? null, + 'disponibilite' => self::couper($profil['disponibilite'] ?? null, 200), + 'disponibilite_code' => $dispo['code'], + 'disponible_le' => $dispo['date'], + 'benevolat' => ($profil['benevolat'] ?? []) ? implode("\n", array_map('trim', $profil['benevolat'])) : null, + 'permis_conduire' => ($profil['permis_conduire'] ?? []) ? self::couper(implode(', ', array_map('trim', $profil['permis_conduire'])), 100) : null, + 'niveau_etudes' => $meta['niveau_etudes'] ?? null, + 'encadrement' => isset($meta['encadrement']) ? (int) $meta['encadrement'] : null, + 'annees_experience' => $sen['annees'], + 'seniorite' => $sen['niveau'], + 'json_source' => json_encode($profil, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'schema_version' => $profil['schema_version'], + ]; + } + + /** + * Enregistre un profil validé et l'indexe. À appeler dans une transaction ouverte par l'appelant. + * @return int identifiant du candidat créé + */ + public static function enregistrer(PDO $pdo, array $profil, int $utilisateurId, int $conservationMois, ?DateTimeImmutable $aujourdhui = null): int + { + $aujourdhui ??= new DateTimeImmutable('today'); + $col = self::colonnes($profil, $aujourdhui) + [ + 'statut' => 'recu', + 'position_kanban' => (int) $pdo->query("SELECT COALESCE(MAX(position_kanban), 0) + 1 FROM candidats WHERE statut = 'recu' AND archive_le IS NULL")->fetchColumn(), + 'texte_recherche' => '', + 'conserver_jusqu_au' => $aujourdhui->modify("+$conservationMois months")->format('Y-m-d'), + 'cree_par' => $utilisateurId, + ]; + $pdo->prepare( + 'INSERT INTO candidats (' . implode(', ', array_keys($col)) . ') VALUES (' . implode(', ', array_fill(0, count($col), '?')) . ')' + )->execute(array_values($col)); + $candidatId = (int) $pdo->lastInsertId(); + + self::ecrireDetails($pdo, $candidatId, $profil, $aujourdhui); + Recherche::indexer($pdo, $candidatId); + return $candidatId; + } + + /** + * Remplace les données d'un profil existant par une nouvelle analyse. + * Conserve le statut, les commentaires, les documents, l'historique et les mots-clés ajoutés à la main. + */ + public static function remplacer(PDO $pdo, int $candidatId, array $profil, int $utilisateurId, ?DateTimeImmutable $aujourdhui = null): void + { + $aujourdhui ??= new DateTimeImmutable('today'); + $col = self::colonnes($profil, $aujourdhui) + ['modifie_par' => $utilisateurId]; + // Disponibilité et nationalités déjà renseignées : le choix d'IT Club prime sur le nouveau CV + $req = $pdo->prepare('SELECT disponibilite_code, nationalites FROM candidats WHERE id = ?'); + $req->execute([$candidatId]); + $actuel = $req->fetch(PDO::FETCH_ASSOC) ?: []; + if (!empty($actuel['disponibilite_code'])) { + unset($col['disponibilite_code'], $col['disponible_le']); + } + if (!empty($actuel['nationalites'])) { + unset($col['nationalites'], $col['hors_ue']); + } + $pdo->prepare( + 'UPDATE candidats SET ' . implode(' = ?, ', array_keys($col)) . ' = ?, modifie_le = NOW() WHERE id = ?' + )->execute([...array_values($col), $candidatId]); + + foreach (['liens', 'experiences', 'formations', 'certifications', 'candidat_technologie', 'langues'] as $table) { + $pdo->prepare("DELETE FROM $table WHERE candidat_id = ?")->execute([$candidatId]); + } + $pdo->prepare('DELETE FROM mots_cles WHERE candidat_id = ? AND manuel = 0')->execute([$candidatId]); + + self::ecrireDetails($pdo, $candidatId, $profil, $aujourdhui); + Recherche::indexer($pdo, $candidatId); + } + + private static function ecrireDetails(PDO $pdo, int $candidatId, array $profil, DateTimeImmutable $aujourdhui): void + { + $req = $pdo->prepare('INSERT INTO liens (candidat_id, type, url) VALUES (?, ?, ?)'); + foreach ($profil['liens'] ?? [] as $l) { + $req->execute([$candidatId, $l['type'], self::couper($l['url'], 500)]); + } + + $req = $pdo->prepare( + 'INSERT INTO experiences (candidat_id, poste, employeur, client, projet, mission, charge, lieu, type, date_debut, date_fin, description, technologies) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + foreach ($profil['experiences'] as $x) { + $req->execute([ + $candidatId, + self::couper($x['poste'], 200), + self::couper($x['employeur'] ?? null, 200), + self::couper($x['client'] ?? null, 200), + self::couper($x['projet'] ?? null, 200), + $x['mission'] ?? null, + self::couper($x['charge'] ?? null, 300), + self::couper($x['lieu'] ?? null, 150), + $x['type'], + self::dateSql($x['date_debut']), + ($x['date_fin'] ?? null) !== null ? self::dateSql($x['date_fin']) : null, + $x['description'] ?? null, + self::couper(implode(', ', $x['technologies'] ?? []), 1000) ?: null, + ]); + } + + $req = $pdo->prepare('INSERT INTO formations (candidat_id, diplome, etablissement, date_debut, date_fin, lieu, annee_fin) VALUES (?, ?, ?, ?, ?, ?, ?)'); + foreach ($profil['formations'] ?? [] as $f) { + // L'année de fin se déduit de la date de fin quand seule celle-ci est donnée + $anneeFin = $f['annee_fin'] ?? (isset($f['date_fin']) ? (int) substr($f['date_fin'], 0, 4) : null); + $req->execute([$candidatId, self::couper($f['diplome'], 250), self::couper($f['etablissement'] ?? null, 250), + $f['date_debut'] ?? null, $f['date_fin'] ?? null, self::couper($f['lieu'] ?? null, 150), $anneeFin]); + } + + $req = $pdo->prepare('INSERT INTO certifications (candidat_id, nom, organisme, annee) VALUES (?, ?, ?, ?)'); + foreach ($profil['certifications'] ?? [] as $c) { + $req->execute([$candidatId, self::couper($c['nom'], 250), self::couper($c['organisme'] ?? null, 250), $c['annee'] ?? null]); + } + + // Technologies : référentiel partagé, sans doublon de casse + $chercher = $pdo->prepare('SELECT id FROM technologies WHERE nom = ?'); + $creer = $pdo->prepare('INSERT INTO technologies (nom, categorie) VALUES (?, ?)'); + $lier = $pdo->prepare('INSERT INTO candidat_technologie (candidat_id, technologie_id, annees) VALUES (?, ?, ?)'); + $vues = []; + foreach ($profil['technologies'] as $t) { + $nom = self::couper(trim($t['nom']), 100); + $cle = mb_strtolower($nom); + if (isset($vues[$cle])) { + continue; + } + $vues[$cle] = true; + $chercher->execute([$nom]); + $technoId = $chercher->fetchColumn(); + if ($technoId === false) { + $creer->execute([$nom, $t['categorie']]); + $technoId = $pdo->lastInsertId(); + } + $mois = self::moisExperience($profil['experiences'], $aujourdhui, $nom); + $annees = $mois > 0 ? self::annees($mois) : ($t['annees'] ?? null); + $lier->execute([$candidatId, (int) $technoId, $annees]); + } + + $req = $pdo->prepare('INSERT INTO langues (candidat_id, langue, niveau, ecoute, lecture, production_orale, interaction_orale, ecriture) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + foreach ($profil['langues'] as $l) { + $g = $l['cecrl'] ?? []; + $req->execute([$candidatId, self::couper($l['langue'], 60), $l['niveau'] ?? null, + $g['ecoute'] ?? null, $g['lecture'] ?? null, $g['production_orale'] ?? null, $g['interaction_orale'] ?? null, $g['ecriture'] ?? null]); + } + + // Mots-clés et métadonnées de recherche. INSERT IGNORE : un mot ajouté à la main reste en place. + $meta = $profil['metadonnees'] ?? []; + $req = $pdo->prepare('INSERT IGNORE INTO mots_cles (candidat_id, type, mot, manuel) VALUES (?, ?, ?, 0)'); + foreach ([ + 'role' => $meta['roles'] ?? [], 'specialite' => $meta['specialites'] ?? [], + 'secteur' => $meta['secteurs'] ?? [], 'mot_cle' => $profil['mots_cles'] ?? [], + ] as $type => $mots) { + foreach ($mots as $mot) { + $req->execute([$candidatId, $type, mb_strtolower(self::couper(trim($mot), 80))]); + } + } + } + + private static function couper(?string $v, int $max): ?string + { + return $v === null ? null : mb_substr($v, 0, $max); + } + + private static function dateSql(string $date): string + { + return strlen($date) === 4 ? $date . '-01-01' : $date . '-01'; + } +} diff --git a/src/Lot.php b/src/Lot.php new file mode 100644 index 0000000..9803bb9 --- /dev/null +++ b/src/Lot.php @@ -0,0 +1,326 @@ +beginTransaction(); + try { + $id = Import::enregistrer($pdo, $profil, (int) utilisateur()['id'], (int) config('conservation_mois')); + journaliser($id, 'creation', null, null, (empty($imp['auto']) ? 'Import de ' : 'Analyse automatique de ') . $profil['source']['fichier_origine']); + attacher_document($id, 'cv_origine', $imp['cv']); + if (!empty($imp['rapport'])) { + attacher_document($id, 'rapport', $imp['rapport']); + } + $photo = dossier_stockage('tmp') . '/' . $imp['dossier'] . '/photo.jpg'; + if (!empty($imp['photo']) && $garderPhoto && is_file($photo)) { + Photo::enregistrer($pdo, $id, (string) file_get_contents($photo)); + } + $pdo->commit(); + } catch (Throwable $ex) { + $pdo->rollBack(); + throw $ex; + } + // Traduction dans les autres langues de l'interface : elle se fait ensuite, en arrière-plan + planifier_traduction($id); + return $id; +} + +/** Supprime un dossier temporaire d'import et son contenu. */ +function vider_dossier_import(string $jeton): void +{ + if (!preg_match('/^[a-f0-9]{32}$/', $jeton)) { + return; + } + $d = dossier_stockage('tmp') . '/' . $jeton; + array_map('unlink', glob($d . '/*') ?: []); + @rmdir($d); +} + +/** + * Import en lot : plusieurs CV déposés d'un coup, chacun analysé puis relu à son tour. + * + * Chaque CV devient une ligne de imports_lot avec son dossier temporaire. L'analyse automatique se fait + * un fichier par requête : une analyse dure une à deux minutes, et l'hébergement coupe les requêtes longues. + */ +final class Lot +{ + /** Jours après lesquels un CV déposé et jamais enregistré est retiré du lot. */ + private const DUREE_JOURS = 7; + + /** Lignes du lot, les plus anciennes d'abord. */ + public static function liste(): array + { + self::nettoyer(); + $lignes = db()->query( + 'SELECT l.*, u.nom_affiche FROM imports_lot l JOIN utilisateurs u ON u.id = l.cree_par ORDER BY l.id' + )->fetchAll(); + foreach ($lignes as &$l) { + $l['meta'] = json_decode((string) $l['meta'], true) ?: []; + $l['doublons'] = []; + if ($l['etat'] === 'pret') { + $profil = self::profil($l); + $l['doublons'] = $profil ? Import::doublons(db(), $profil) : []; + } + } + return $lignes; + } + + public static function ligne(int $id): ?array + { + $req = db()->prepare('SELECT * FROM imports_lot WHERE id = ?'); + $req->execute([$id]); + $l = $req->fetch(); + if (!$l) { + return null; + } + $l['meta'] = json_decode((string) $l['meta'], true) ?: []; + return $l; + } + + public static function nombre(): int + { + return (int) db()->query('SELECT COUNT(*) FROM imports_lot')->fetchColumn(); + } + + /** Profil analysé d'une ligne prête, ou null. */ + public static function profil(array $ligne): ?array + { + $f = dossier_stockage('tmp') . '/' . $ligne['jeton'] . '/profil.json'; + $p = is_file($f) ? json_decode((string) file_get_contents($f), true) : null; + return is_array($p) ? $p : null; + } + + /** + * Dépose un CV à analyser. + * @param array $f caractéristiques renvoyées par controler_fichier() + */ + public static function deposer(array $f): int + { + [$jeton, $dossier] = self::nouveauDossier(); + $f['chemin_tmp'] = $dossier . '/cv.' . $f['ext']; + if (!move_uploaded_file($f['tmp'], $f['chemin_tmp'])) { + throw new RuntimeException(t('Le fichier n\'a pas pu être enregistré.')); + } + unset($f['tmp']); + return self::inserer($jeton, $f['nom'], 'en_attente', null, null, ['mode' => 'auto', 'cv' => $f]); + } + + /** + * Dépose un profil déjà analysé, avec son CV et son rapport. + * @param array $json fichier JSON, $cv CV d'origine, $rapport rapport ou null : caractéristiques de controler_fichier() + * @return int identifiant de la ligne, prête ou en erreur + */ + public static function deposerAnalyse(array $json, array $cv, ?array $rapport): int + { + [$jeton, $dossier] = self::nouveauDossier(); + $profil = json_decode((string) file_get_contents($json['tmp']), true); + $erreurs = json_last_error() !== JSON_ERROR_NONE ? [t('Le fichier de profil n\'est pas un JSON valide.')] : Import::valider($profil); + + $ranger = static function (array $f, string $cle) use ($dossier): array { + $f['chemin_tmp'] = $dossier . '/' . $cle . '.' . $f['ext']; + move_uploaded_file($f['tmp'], $f['chemin_tmp']); + unset($f['tmp']); + return $f; + }; + $meta = ['mode' => 'manuel', 'cv' => $ranger($cv, 'cv'), 'rapport' => $rapport ? $ranger($rapport, 'rapport') : null, 'photo' => false]; + if ($erreurs) { + return self::inserer($jeton, $cv['nom'], 'erreur', self::resumer($erreurs), null, $meta); + } + file_put_contents($dossier . '/profil.json', json_encode($profil, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + $meta['photo'] = self::chercherPhoto($dossier, $meta['cv']); + return self::inserer($jeton, $cv['nom'], 'pret', null, $profil['identite']['prenom'] . ' ' . $profil['identite']['nom'], $meta); + } + + /** + * Analyse le CV d'une ligne en attente par l'API Claude. La ligne passe à « prêt » ou à « erreur ». + * @return array la ligne mise à jour + */ + public static function analyser(int $id): array + { + $l = self::ligne($id); + if ($l === null) { + throw new RuntimeException(t('Ce CV n\'est plus dans le lot.')); + } + if ($l['etat'] === 'pret' || ($l['meta']['mode'] ?? '') !== 'auto') { + return $l; + } + // Deux onglets ouverts sur le lot : un seul lance l'analyse, l'autre retrouve la ligne telle quelle + $verrou = db()->prepare('SELECT GET_LOCK(?, 0)'); + $verrou->execute(['vivier_lot_' . $id]); + if ((int) $verrou->fetchColumn() !== 1) { + return $l; + } + $dossier = dossier_stockage('tmp') . '/' . $l['jeton']; + $cv = $l['meta']['cv']; + try { + if (!Claude::configure()) { + throw new RuntimeException(t('L\'analyse automatique n\'est pas configurée.')); + } + $analyse = Claude::analyser($cv + ['tmp' => $cv['chemin_tmp']]); + $erreurs = Import::valider($analyse['profil']); + if ($erreurs) { + throw new RuntimeException(t('L\'analyse automatique a produit un profil non conforme. Relancez-la, ou passez par les fichiers déjà analysés.') . ' ' . self::resumer($erreurs)); + } + $profil = $analyse['profil']; + file_put_contents($dossier . '/profil.json', json_encode($profil, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + $meta = ['mode' => 'auto', 'cv' => $cv, 'rapport' => null, 'auto' => $analyse['modele']]; + if ($analyse['rapport'] !== '') { + $base = preg_replace('/[^a-z0-9]+/', '-', Recherche::normaliser($profil['identite']['nom'] . ' ' . $profil['identite']['prenom'])); + $meta['rapport'] = ['nom' => 'rapport-' . trim((string) $base, '-') . '.md', 'ext' => 'md', 'mime' => 'text/markdown', + 'taille' => strlen($analyse['rapport']), 'chemin_tmp' => $dossier . '/rapport.md']; + file_put_contents($meta['rapport']['chemin_tmp'], $analyse['rapport']); + } + $meta['photo'] = self::chercherPhoto($dossier, $cv); + db()->prepare("UPDATE imports_lot SET etat = 'pret', erreur = NULL, candidat = ?, meta = ? WHERE id = ?") + ->execute([mb_substr($profil['identite']['prenom'] . ' ' . $profil['identite']['nom'], 0, 200), json_encode($meta, JSON_UNESCAPED_UNICODE), $id]); + } catch (RuntimeException $ex) { + db()->prepare("UPDATE imports_lot SET etat = 'erreur', erreur = ? WHERE id = ?")->execute([mb_substr($ex->getMessage(), 0, 1000), $id]); + } finally { + db()->prepare('SELECT RELEASE_LOCK(?)')->execute(['vivier_lot_' . $id]); + } + return (array) self::ligne($id); + } + + /** Fichiers d'une ligne prête, dans la forme attendue par l'écran de relecture et par enregistrer_import(). */ + public static function enAttente(array $ligne): array + { + return ['dossier' => $ligne['jeton'], 'lot' => (int) $ligne['id']] + $ligne['meta']; + } + + /** + * Enregistre toutes les lignes prêtes qui ne ressemblent à aucun profil existant. + * @return array{enregistres:int, a_relire:int} + */ + public static function toutEnregistrer(): array + { + $enregistres = 0; + $aRelire = 0; + foreach (self::liste() as $l) { + if ($l['etat'] !== 'pret') { + continue; + } + $profil = self::profil($l); + // Chaque profil enregistré peut devenir le doublon du suivant : le contrôle se refait à chaque tour + if ($profil === null || Import::doublons(db(), $profil)) { + $aRelire++; + continue; + } + enregistrer_import($profil, self::enAttente($l), true); + self::retirer((int) $l['id']); + $enregistres++; + } + return ['enregistres' => $enregistres, 'a_relire' => $aRelire]; + } + + /** Retire une ligne et son dossier temporaire. */ + public static function retirer(int $id): void + { + $l = self::ligne($id); + if ($l !== null) { + vider_dossier_import($l['jeton']); + db()->prepare('DELETE FROM imports_lot WHERE id = ?')->execute([$id]); + } + } + + /** + * Associe des fichiers déposés ensemble : chaque profil JSON avec le CV que nomme son champ source.fichier_origine, + * et avec le rapport dont le nom commence comme celui du JSON ou contient le nom du candidat. + * @param array[] $fichiers caractéristiques renvoyées par controler_fichier() + * @return array{paires: array, erreurs: string[]} + */ + public static function apparier(array $fichiers): array + { + $json = array_values(array_filter($fichiers, static fn ($f) => $f['ext'] === 'json')); + $autres = array_values(array_filter($fichiers, static fn ($f) => $f['ext'] !== 'json')); + $paires = []; + $erreurs = []; + $pris = []; + $cle = static fn (string $nom): string => trim(preg_replace('/[^a-z0-9]+/', ' ', Recherche::normaliser(pathinfo($nom, PATHINFO_FILENAME))) ?? ''); + + foreach ($json as $j) { + $profil = json_decode((string) file_get_contents($j['tmp']), true); + $origine = is_array($profil) ? (string) ($profil['source']['fichier_origine'] ?? '') : ''; + $cv = null; + foreach ($autres as $i => $f) { + if (!isset($pris[$i]) && $origine !== '' && mb_strtolower($f['nom']) === mb_strtolower(basename($origine))) { + $cv = $f; + $pris[$i] = true; + break; + } + } + if ($cv === null) { + $erreurs[] = $origine === '' + ? t('%s : profil illisible ou sans nom de CV d\'origine.', $j['nom']) + : t('%s : le CV « %s » nommé dans ce profil n\'est pas parmi les fichiers déposés.', $j['nom'], $origine); + continue; + } + // Rapport : un .md ou .txt dont le nom reprend celui du JSON, ou le nom du candidat + $nomCandidat = is_array($profil['identite'] ?? null) ? $cle((string) ($profil['identite']['nom'] ?? '')) : ''; + $rapport = null; + foreach ($autres as $i => $f) { + if (isset($pris[$i]) || !in_array($f['ext'], ['md', 'txt'], true)) { + continue; + } + $nom = $cle($f['nom']); + if (str_contains($nom, $cle($j['nom'])) || ($nomCandidat !== '' && str_contains(" $nom ", " $nomCandidat "))) { + $rapport = $f; + $pris[$i] = true; + break; + } + } + $paires[] = ['json' => $j, 'cv' => $cv, 'rapport' => $rapport]; + } + foreach ($autres as $i => $f) { + if (!isset($pris[$i])) { + $erreurs[] = t('%s : aucun profil JSON ne nomme ce fichier.', $f['nom']); + } + } + return ['paires' => $paires, 'erreurs' => $erreurs]; + } + + // ----------------------------------------------------------------------- + + private static function nouveauDossier(): array + { + $jeton = bin2hex(random_bytes(16)); + return [$jeton, dossier_stockage('tmp/' . $jeton)]; + } + + private static function inserer(string $jeton, string $nom, string $etat, ?string $erreur, ?string $candidat, array $meta): int + { + db()->prepare('INSERT INTO imports_lot (jeton, nom_fichier, etat, erreur, candidat, meta, cree_par) VALUES (?, ?, ?, ?, ?, ?, ?)') + ->execute([$jeton, mb_substr($nom, 0, 255), $etat, $erreur, $candidat !== null ? mb_substr($candidat, 0, 200) : null, + json_encode($meta, JSON_UNESCAPED_UNICODE), utilisateur()['id']]); + return (int) db()->lastInsertId(); + } + + private static function chercherPhoto(string $dossier, array $cv): bool + { + $photo = Photo::extraire($cv['chemin_tmp'], $cv['ext']); + if ($photo === null) { + return false; + } + file_put_contents($dossier . '/photo.jpg', $photo); + return true; + } + + private static function resumer(array $erreurs): string + { + return mb_substr(implode(' ', array_slice($erreurs, 0, 5)) . (count($erreurs) > 5 ? ' …' : ''), 0, 1000); + } + + /** Retire les CV déposés depuis trop longtemps : leurs fichiers ne restent pas indéfiniment dans le dossier temporaire. */ + private static function nettoyer(): void + { + $req = db()->query('SELECT id FROM imports_lot WHERE cree_le < DATE_SUB(NOW(), INTERVAL ' . self::DUREE_JOURS . ' DAY)'); + foreach ($req->fetchAll(PDO::FETCH_COLUMN) as $id) { + self::retirer((int) $id); + } + } +} diff --git a/src/Photo.php b/src/Photo.php new file mode 100644 index 0000000..101a4b0 --- /dev/null +++ b/src/Photo.php @@ -0,0 +1,256 @@ + self::TAILLE_MAX_CV) { + return null; + } + try { + $images = match ($extension) { + 'docx', 'odt' => self::imagesArchive($chemin), + 'pdf' => self::imagesPdf((string) file_get_contents($chemin)), + default => [], + }; + } catch (Throwable) { + return null; // un CV atypique ne doit jamais bloquer l'import + } + return self::choisir($images); + } + + /** Images contenues dans un fichier Word ou OpenDocument. */ + private static function imagesArchive(string $chemin): array + { + $zip = new ZipArchive(); + if ($zip->open($chemin) !== true) { + return []; + } + $images = []; + for ($i = 0; $i < $zip->numFiles && count($images) < self::IMAGES_MAX; $i++) { + $nom = (string) $zip->getNameIndex($i); + if (preg_match('#^(word/media|Pictures)/[^/]+\.(jpe?g|png|gif|webp|bmp)$#i', $nom)) { + $images[] = (string) $zip->getFromIndex($i); + } + } + $zip->close(); + return $images; + } + + /** + * Images d'un PDF. Lit les objets image JPEG (DCTDecode) et les images compressées sans perte + * (FlateDecode, 8 bits, gris ou RVB), qu'il remet en PNG. + */ + private static function imagesPdf(string $pdf): array + { + $images = []; + $pos = 0; + while (count($images) < self::IMAGES_MAX && ($pos = strpos($pdf, 'stream', $pos)) !== false) { + $debut = $pos + 6; + $pos = $debut; + if (substr($pdf, $debut - 9, 3) === 'end') { + continue; // « endstream » + } + // Dictionnaire de l'objet : entre le « obj » précédent et « stream » + $avant = substr($pdf, max(0, $debut - 3000), min(3000, $debut) - 6); + $obj = strrpos($avant, ' obj'); + $dico = $obj === false ? $avant : substr($avant, $obj); + if (!preg_match('#/Subtype\s*/Image\b#', $dico) || !preg_match('#/Width\s+(\d+)#', $dico, $l) || !preg_match('#/Height\s+(\d+)#', $dico, $h)) { + continue; + } + if (preg_match('#/(Indexed|ImageMask|SMaskInData)\b#', $dico)) { + continue; // image à palette ou masque : pas une photo exploitable telle quelle + } + if (!self::formatPortrait((int) $l[1], (int) $h[1])) { + continue; // bandeau ou filet : inutile de le décoder + } + $debut += $pdf[$debut] === "\r" ? 2 : 1; + $fin = strpos($pdf, 'endstream', $debut); + if ($fin === false) { + break; + } + $donnees = preg_match('#/Length\s+(\d+)\b(?!\s+\d+\s+R\b)#', $dico, $n) && (int) $n[1] <= $fin - $debut + ? substr($pdf, $debut, (int) $n[1]) + : rtrim(substr($pdf, $debut, $fin - $debut), "\r\n"); + $pos = $fin + 9; + + preg_match_all('#/(DCTDecode|FlateDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode|LZWDecode|ASCII85Decode|ASCIIHexDecode|RunLengthDecode)\b#', $dico, $f); + $filtres = $f[1]; + if ($filtres === ['FlateDecode', 'DCTDecode']) { + $donnees = (string) @gzuncompress($donnees); + $filtres = ['DCTDecode']; + } + if ($filtres === ['DCTDecode']) { + $images[] = $donnees; + } elseif ($filtres === ['FlateDecode'] && preg_match('#/BitsPerComponent\s+8\b#', $dico)) { + $png = self::pngDepuisFlate($donnees, (int) $l[1], (int) $h[1], (bool) preg_match('#/Predictor\s+1[0-5]\b#', $dico)); + if ($png !== null) { + $images[] = $png; + } + } + } + return $images; + } + + /** Un portrait est plutôt vertical ou carré : largeur entre la moitié et 1,3 fois la hauteur. */ + private static function formatPortrait(int $l, int $h): bool + { + if ($h <= 0) { + return false; + } + $rapport = $l / $h; + return $rapport >= 0.5 && $rapport <= 1.3; + } + + /** + * Reconstruit un PNG à partir des données brutes d'une image PDF. + * Les lignes d'un PNG sont compressées de la même façon : il suffit de les réemballer. + */ + private static function pngDepuisFlate(string $donnees, int $l, int $h, bool $predicteur): ?string + { + if ($l < self::COTE_MIN || $h < self::COTE_MIN || $l * $h > 6000000) { + return null; + } + $brut = @gzuncompress($donnees); + if ($brut === false) { + return null; + } + $parLigne = intdiv(strlen($brut), $h) - ($predicteur ? 1 : 0); + $canaux = intdiv($parLigne, $l); + if (!in_array($canaux, [1, 3], true) || $canaux * $l !== $parLigne) { + return null; // CMJN, palette ou image tronquée + } + if (!$predicteur) { + $lignes = ''; + for ($y = 0; $y < $h; $y++) { + $lignes .= "\0" . substr($brut, $y * $parLigne, $parLigne); + } + $brut = $lignes; + } + $bloc = static fn (string $type, string $d): string => pack('N', strlen($d)) . $type . $d . pack('N', crc32($type . $d)); + return "\x89PNG\r\n\x1a\n" + . $bloc('IHDR', pack('NNCCCCC', $l, $h, 8, $canaux === 3 ? 2 : 0, 0, 0, 0)) + . $bloc('IDAT', (string) gzcompress($brut, 6)) + . $bloc('IEND', ''); + } + + /** + * Garde l'image qui ressemble le plus à un portrait : assez grande, plutôt verticale ou carrée, + * et sans grande plage unie. Seuils réglés sur des photos et des logos réels ; un portrait détouré + * sur un fond blanc très large peut être écarté : il se charge alors à la main. + * @param string[] $images contenus binaires + */ + public static function choisir(array $images): ?string + { + $meilleure = null; + $meilleurScore = 0; + foreach ($images as $binaire) { + $t = @getimagesizefromstring($binaire); + if (!$t || $t[0] < self::COTE_MIN || $t[1] < self::COTE_MIN || $t[0] * $t[1] > 40000000) { + continue; + } + if (!self::formatPortrait($t[0], $t[1])) { + continue; // bandeau, filet, signature + } + $img = @imagecreatefromstring($binaire); + if (!$img) { + continue; + } + // Vignette 24 × 24 ramenée à 64 niveaux de gris. Un logo ou un pictogramme a une grande plage unie : + // un même niveau couvre plus du tiers de l'image. Une photo étale ses nuances. + $v = imagecreatetruecolor(24, 24); + imagefill($v, 0, 0, imagecolorallocate($v, 255, 255, 255)); + imagecopyresampled($v, $img, 0, 0, 0, 0, 24, 24, $t[0], $t[1]); + $niveaux = []; + for ($y = 0; $y < 24; $y++) { + for ($x = 0; $x < 24; $x++) { + $c = imagecolorat($v, $x, $y); + $gris = (int) (((($c >> 16) & 255) * 3 + (($c >> 8) & 255) * 6 + ($c & 255)) / 10) >> 2; + $niveaux[$gris] = ($niveaux[$gris] ?? 0) + 1; + } + } + $dominante = max($niveaux) / 576; + if (count($niveaux) < 28 || $dominante > 0.35) { + continue; + } + $score = min($t[0] * $t[1], 500000) * (1 - $dominante); + if ($score > $meilleurScore) { + $meilleurScore = $score; + $meilleure = $binaire; + } + } + return $meilleure === null ? null : self::normaliser($meilleure); + } + + /** Réduit l'image et la réenregistre en JPEG sur fond blanc. */ + public static function normaliser(string $binaire): ?string + { + if (!self::disponible()) { + return null; + } + $t = @getimagesizefromstring($binaire); + $img = $t && $t[0] * $t[1] <= 40000000 ? @imagecreatefromstring($binaire) : false; + if (!$img) { + return null; + } + $echelle = min(1, self::COTE_MAX / max($t[0], $t[1])); + $l = max(1, (int) round($t[0] * $echelle)); + $h = max(1, (int) round($t[1] * $echelle)); + $sortie = imagecreatetruecolor($l, $h); + imagefill($sortie, 0, 0, imagecolorallocate($sortie, 255, 255, 255)); + imagecopyresampled($sortie, $img, 0, 0, 0, 0, $l, $h, $t[0], $t[1]); + ob_start(); + imagejpeg($sortie, null, 86); + return (string) ob_get_clean(); + } + + // ----------------------------------------------------------------------- + // Enregistrement + // ----------------------------------------------------------------------- + + /** Enregistre un JPEG déjà normalisé comme photo du candidat. */ + public static function enregistrer(PDO $pdo, int $candidatId, string $jpeg): void + { + self::supprimer($pdo, $candidatId); + $relatif = 'photos/' . bin2hex(random_bytes(16)) . '.jpg'; + $cible = dossier_stockage('photos') . '/' . basename($relatif); + if (file_put_contents($cible, $jpeg) === false) { + throw new RuntimeException(t('La photo n\'a pas pu être enregistrée.')); + } + chmod($cible, 0600); + $pdo->prepare('UPDATE candidats SET photo = ? WHERE id = ?')->execute([$relatif, $candidatId]); + } + + public static function supprimer(PDO $pdo, int $candidatId): void + { + $req = $pdo->prepare('SELECT photo FROM candidats WHERE id = ?'); + $req->execute([$candidatId]); + $ancienne = $req->fetchColumn(); + if ($ancienne) { + @unlink(dossier_stockage() . '/' . $ancienne); + $pdo->prepare('UPDATE candidats SET photo = NULL WHERE id = ?')->execute([$candidatId]); + } + } +} diff --git a/src/Recherche.php b/src/Recherche.php new file mode 100644 index 0000000..17ea345 --- /dev/null +++ b/src/Recherche.php @@ -0,0 +1,387 @@ + 8, 'titre' => 6, 'role' => 6, 'specialite' => 5, 'technologie' => 5, 'secteur' => 4, 'mot_cle' => 4, + 'poste' => 3, 'langue' => 3, 'seniorite' => 3, 'resume' => 2, 'employeur' => 2, 'formation' => 2, 'lieu' => 2, + 'description' => 1, + ]; + + private const MOTS_VIDES = [ + 'de', 'du', 'des', 'd', 'le', 'la', 'les', 'l', 'un', 'une', 'et', 'ou', 'en', 'a', 'au', 'aux', 'avec', 'pour', 'sur', + 'dans', 'par', 'qui', 'the', 'of', 'and', 'or', 'in', 'with', 'for', + ]; + + private const ACCENTS = [ + 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'á' => 'a', 'ã' => 'a', 'å' => 'a', 'ç' => 'c', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', + 'í' => 'i', 'ì' => 'i', 'î' => 'i', 'ï' => 'i', 'ñ' => 'n', 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ö' => 'o', 'õ' => 'o', + 'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ý' => 'y', 'ÿ' => 'y', 'œ' => 'oe', 'æ' => 'ae', 'ß' => 'ss', '’' => "'", + ]; + + // ----------------------------------------------------------------------- + // Normalisation + // ----------------------------------------------------------------------- + + /** Mot trop courant pour compter dans une recherche ou un rapprochement. */ + public static function motVide(string $terme): bool + { + return in_array($terme, self::MOTS_VIDES, true); + } + + public static function normaliser(string $texte): string + { + $t = strtr(mb_strtolower($texte, 'UTF-8'), self::ACCENTS); + // « .NET » perdrait son point au découpage + $t = preg_replace('/(?prepare($sql); + $r->execute([$id]); + return $r->fetchAll(PDO::FETCH_ASSOC); + }; + $c = $lire('SELECT * FROM candidats WHERE id = ?')[0] ?? null; + if ($c === null) { + return; + } + + $poids = []; + $texte = []; + // Un terme compte une fois par appel : dix descriptions qui répètent « développement » ne le font pas monter. + $ajouter = static function (?string $t, string $source, int $bonus = 0) use (&$poids, &$texte): void { + if ($t === null || trim($t) === '') { + return; + } + $texte[] = $t; + foreach (self::termes($t) as $terme) { + if (!in_array($terme, self::MOTS_VIDES, true)) { + $poids[$terme] = min(60, ($poids[$terme] ?? 0) + self::POIDS[$source] + $bonus); + } + } + }; + $joindre = static function (array $lignes, string ...$champs): string { + $morceaux = []; + foreach ($lignes as $l) { + foreach ($champs as $ch) { + $morceaux[] = (string) ($l[$ch] ?? ''); + } + } + return trim(implode(' ', array_filter($morceaux, 'strlen'))); + }; + + $ajouter($c['prenom'] . ' ' . $c['nom'], 'nom'); + $ajouter($c['titre'], 'titre'); + $ajouter($c['resume'], 'resume'); + $ajouter(SENIORITES[$c['seniorite']] ?? $c['seniorite'], 'seniorite'); + $ajouter(trim(($c['ville'] ?? '') . ' ' . ($c['pays'] ?? '')), 'lieu'); + $ajouter($c['niveau_etudes'], 'formation'); + + foreach ($lire('SELECT type, mot FROM mots_cles WHERE candidat_id = ?') as $m) { + $ajouter($m['mot'], $m['type']); + } + foreach ($lire('SELECT t.nom, ct.annees FROM candidat_technologie ct JOIN technologies t ON t.id = ct.technologie_id WHERE ct.candidat_id = ?') as $t) { + // Jusqu'à 5 points de plus pour les technologies les plus pratiquées + $ajouter($t['nom'], 'technologie', (int) min(5, round((float) $t['annees'] / 2))); + } + + $exp = $lire('SELECT poste, employeur, client, lieu, description, technologies FROM experiences WHERE candidat_id = ?'); + $ajouter($joindre($exp, 'poste'), 'poste'); + $ajouter($joindre($exp, 'employeur', 'client'), 'employeur'); + $ajouter($joindre($exp, 'lieu'), 'lieu'); + $ajouter($joindre($exp, 'description', 'technologies'), 'description'); + + $ajouter($joindre($lire('SELECT langue, niveau FROM langues WHERE candidat_id = ?'), 'langue', 'niveau'), 'langue'); + $ajouter($joindre($lire('SELECT diplome, etablissement FROM formations WHERE candidat_id = ?'), 'diplome', 'etablissement'), 'formation'); + $ajouter($joindre($lire('SELECT nom, organisme FROM certifications WHERE candidat_id = ?'), 'nom', 'organisme'), 'formation'); + $ajouter($c['permis_travail'] ?? null, 'description'); + + // Textes traduits : « Java developer » trouve un profil écrit en français, et l'inverse + foreach ($lire('SELECT contenu FROM traductions WHERE candidat_id = ?') as $tr) { + $t = json_decode((string) $tr['contenu'], true); + if (!is_array($t)) { + continue; + } + $texteDe = static fn (mixed $v): ?string => is_string($v) ? $v : null; + $ajouter($texteDe($t['titre'] ?? null), 'titre'); + $ajouter($texteDe($t['resume'] ?? null), 'resume'); + $exp = array_filter((array) ($t['experiences'] ?? []), 'is_array'); + $ajouter($joindre($exp, 'poste'), 'poste'); + $ajouter($joindre($exp, 'description', 'mission'), 'description'); + $ajouter(implode(' ', array_filter([...array_values((array) ($t['formations'] ?? [])), ...array_values((array) ($t['certifications'] ?? []))], 'is_string')), 'formation'); + foreach ((array) ($t['metiers'] ?? []) as $m) { + $ajouter($texteDe($m), 'role'); + } + } + + $pdo->prepare('DELETE FROM index_recherche WHERE candidat_id = ?')->execute([$id]); + foreach (array_chunk(array_keys($poids), 200) as $lot) { + $valeurs = []; + foreach ($lot as $terme) { + array_push($valeurs, $id, (string) $terme, $poids[$terme]); + } + $pdo->prepare('INSERT INTO index_recherche (candidat_id, terme, poids) VALUES ' . implode(', ', array_fill(0, count($lot), '(?, ?, ?)'))) + ->execute($valeurs); + } + $pdo->prepare('UPDATE candidats SET texte_recherche = ? WHERE id = ?') + ->execute([trim(preg_replace('/\s+/u', ' ', implode(' ', $texte)) ?? ''), $id]); + } + + /** @return int nombre de profils réindexés */ + public static function toutIndexer(PDO $pdo): int + { + $ids = $pdo->query('SELECT id FROM candidats')->fetchAll(PDO::FETCH_COLUMN); + foreach ($ids as $id) { + self::indexer($pdo, (int) $id); + } + return count($ids); + } + + // ----------------------------------------------------------------------- + // Interrogation + // ----------------------------------------------------------------------- + + /** + * Analyse une requête saisie. + * @return array{groupes: string[][], phrases: string[], seniorites: string[]} + * groupes : pour chaque mot, la liste de ses variantes (synonymes, singulier) + */ + public static function analyser(PDO $pdo, string $requete): array + { + $phrases = []; + $reste = preg_replace_callback('/"([^"]{2,})"/u', static function ($m) use (&$phrases) { + $phrases[] = trim($m[1]); + return ' '; + }, $requete) ?? $requete; + + // Synonymes : un mot devient un filtre de séniorité, ou gagne des variantes + $versSeniorite = []; + $versTexte = []; + foreach ($pdo->query('SELECT terme, cible_type, cible FROM synonymes_recherche') as $s) { + $cle = self::normaliser($s['terme']); + if ($s['cible_type'] === 'seniorite') { + $versSeniorite[$cle][] = $s['cible']; + } else { + $versTexte[$cle][] = $s['cible']; + } + } + + $groupes = []; + $seniorites = []; + foreach (self::termes($reste, false) as $mot) { + if (in_array($mot, self::MOTS_VIDES, true)) { + continue; + } + if (isset($versSeniorite[$mot])) { + $seniorites = array_merge($seniorites, $versSeniorite[$mot]); + continue; + } + $variantes = [$mot]; + if (strlen($mot) > 4 && str_ends_with($mot, 's')) { + $variantes[] = substr($mot, 0, -1); + } + foreach ($versTexte[$mot] ?? [] as $cible) { + // Une cible de plusieurs mots (« product owner ») : son premier mot significatif suffit à la retrouver + $variantes[] = self::termes($cible, false)[0] ?? $mot; + } + $groupes[] = array_values(array_unique($variantes)); + } + + return ['groupes' => $groupes, 'phrases' => $phrases, 'seniorites' => array_values(array_unique($seniorites))]; + } + + /** + * @param array{statut?:string, seniorite?:string, langue?:string, archives?:string, nationalite?:string, permis?:string} $filtres + * archives : vide = profils actifs seulement, « inclus » = tous, « seuls » = archivés seulement + * @return array{resultats: array, analyse: array} + */ + public static function chercher(PDO $pdo, string $requete, array $filtres = []): array + { + $analyse = self::analyser($pdo, $requete); + $scores = null; // null : aucun mot à chercher, tous les profils restent candidats + + foreach ($analyse['groupes'] as $variantes) { + $conditions = []; + $params = []; + foreach ($variantes as $v) { + // Un ou deux caractères (« c », « r », « go ») : correspondance exacte, sinon tout remonterait + if (strlen($v) <= 2) { + $conditions[] = 'terme = ?'; + $params[] = $v; + } else { + $conditions[] = "terme LIKE ? ESCAPE '!'"; + $params[] = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $v) . '%'; + } + } + $exacts = implode(', ', array_fill(0, count($variantes), '?')); + $req = $pdo->prepare( + "SELECT candidat_id, MAX(CASE WHEN terme IN ($exacts) THEN poids * 2 ELSE poids END) AS score + FROM index_recherche WHERE " . implode(' OR ', $conditions) . ' GROUP BY candidat_id' + ); + $req->execute([...$variantes, ...$params]); + $trouves = $req->fetchAll(PDO::FETCH_KEY_PAIR); + + if ($scores === null) { + $scores = array_map('intval', $trouves); + } else { + foreach ($scores as $id => $s) { + if (isset($trouves[$id])) { + $scores[$id] = $s + (int) $trouves[$id]; + } else { + unset($scores[$id]); + } + } + } + if ($scores === []) { + break; + } + } + + if ($scores === []) { + return ['resultats' => [], 'analyse' => $analyse]; + } + + // Filtres et phrases exactes, appliqués en SQL + $ou = ['1 = 1']; + $params = []; + if ($scores !== null) { + $ou[] = 'c.id IN (' . implode(', ', array_map('intval', array_keys($scores))) . ')'; + } + foreach ($analyse['phrases'] as $phrase) { + $ou[] = "c.texte_recherche LIKE ? ESCAPE '!'"; + $params[] = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $phrase) . '%'; + } + $seniorites = $analyse['seniorites']; + if (!empty($filtres['seniorite']) && isset(SENIORITES[$filtres['seniorite']])) { + $seniorites = $seniorites ? array_values(array_intersect($seniorites, [$filtres['seniorite']])) : [$filtres['seniorite']]; + if (!$seniorites) { + return ['resultats' => [], 'analyse' => $analyse]; + } + } + if ($seniorites) { + $ou[] = 'c.seniorite IN (' . implode(', ', array_fill(0, count($seniorites), '?')) . ')'; + $params = array_merge($params, $seniorites); + } + if (!empty($filtres['statut']) && isset(STATUTS[$filtres['statut']])) { + $ou[] = 'c.statut = ?'; + $params[] = $filtres['statut']; + } + // Nationalité : UE, EEE ou Suisse (pas de permis de travail), hors UE, ou non renseignée + $nat = (string) ($filtres['nationalite'] ?? ''); + if ($nat === 'ue') { + $ou[] = 'c.hors_ue = 0'; + } elseif ($nat === 'hors_ue') { + $ou[] = 'c.hors_ue = 1'; + } elseif ($nat === 'inconnue') { + $ou[] = 'c.hors_ue IS NULL'; + } + // Titre de séjour qui expire dans les 2 mois, ou déjà expiré + if (($filtres['permis'] ?? '') === 'expire') { + $ou[] = '(c.hors_ue IS NULL OR c.hors_ue = 1) AND c.permis_expire_le IS NOT NULL AND c.permis_expire_le <= DATE_ADD(CURDATE(), INTERVAL 2 MONTH)'; + } + if (!empty($filtres['langue'])) { + $ou[] = 'EXISTS (SELECT 1 FROM langues l WHERE l.candidat_id = c.id AND l.langue = ?)'; + $params[] = $filtres['langue']; + } + // Un profil à la corbeille ne sort dans aucune recherche + $ou[] = 'c.supprime_le IS NULL'; + $archives = (string) ($filtres['archives'] ?? ''); + if ($archives !== 'inclus') { + $ou[] = $archives === 'seuls' ? 'c.archive_le IS NOT NULL' : 'c.archive_le IS NULL'; + } + + $req = $pdo->prepare( + 'SELECT c.id, c.prenom, c.nom, c.titre, c.seniorite, c.annees_experience, c.statut, c.ville, c.pays, c.disponibilite, c.disponibilite_code, c.disponible_le, + c.hors_ue, c.permis_expire_le, c.cree_le, c.photo, c.archive_le + FROM candidats c WHERE ' . implode(' AND ', $ou) . ' ORDER BY c.nom, c.prenom LIMIT 500' + ); + $req->execute($params); + $lignes = $req->fetchAll(PDO::FETCH_ASSOC); + if (!$lignes) { + return ['resultats' => [], 'analyse' => $analyse]; + } + + // Technologies et mots-clés de chaque résultat, en signalant ceux que la requête a touchés + $ids = implode(', ', array_map(static fn ($l) => (int) $l['id'], $lignes)); + $cherches = array_merge(...($analyse['groupes'] ?: [[]])); + $touche = static function (string $libelle) use ($cherches): bool { + foreach (self::termes($libelle) as $terme) { + foreach ($cherches as $v) { + if ($terme === $v || (strlen($v) > 2 && str_starts_with($terme, $v))) { + return true; + } + } + } + return false; + }; + $technos = []; + foreach ($pdo->query( + "SELECT ct.candidat_id, t.nom, ct.annees FROM candidat_technologie ct JOIN technologies t ON t.id = ct.technologie_id + WHERE ct.candidat_id IN ($ids) ORDER BY ct.annees DESC, t.nom" + ) as $t) { + $technos[$t['candidat_id']][] = ['nom' => $t['nom'], 'annees' => $t['annees'], 'trouve' => $touche($t['nom'])]; + } + $mots = []; + foreach ($pdo->query("SELECT candidat_id, mot FROM mots_cles WHERE candidat_id IN ($ids) AND type <> 'mot_cle' ORDER BY type, mot") as $m) { + $mots[$m['candidat_id']][] = ['mot' => $m['mot'], 'trouve' => $touche($m['mot'])]; + } + + foreach ($lignes as &$l) { + $l['score'] = $scores[$l['id']] ?? 0; + // Les technologies touchées d'abord, puis les plus pratiquées : 6 au plus + $t = $technos[$l['id']] ?? []; + usort($t, static fn ($a, $b) => [$b['trouve'], (float) $b['annees']] <=> [$a['trouve'], (float) $a['annees']]); + $l['technologies'] = array_slice($t, 0, 6); + $l['mots'] = $mots[$l['id']] ?? []; + } + unset($l); + if ($scores !== null) { + usort($lignes, static fn ($a, $b) => [$b['score'], $a['nom']] <=> [$a['score'], $b['nom']]); + } + + return ['resultats' => $lignes, 'analyse' => $analyse]; + } +} diff --git a/src/Sauvegarde.php b/src/Sauvegarde.php new file mode 100644 index 0000000..c7a8907 --- /dev/null +++ b/src/Sauvegarde.php @@ -0,0 +1,407 @@ + bool. */ + public static $transport = null; + + public static function reglages(): array + { + $c = (array) (config('sauvegarde') ?? []); + return [ + 'destinataires' => array_values(array_filter(array_map('trim', (array) ($c['destinataires'] ?? [])), static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL) !== false)), + 'expediteur' => filter_var((string) ($c['expediteur'] ?? ''), FILTER_VALIDATE_EMAIL) ?: '', + 'semaines_conservees' => max(0, (int) ($c['semaines_conservees'] ?? 0)), + 'taille_max_email' => (int) ($c['taille_max_email'] ?? 7 * 1024 * 1024), + ]; + } + + public static function dossier(): string + { + return dossier_stockage('sauvegardes'); + } + + // ----------------------------------------------------------------------- + // Export SQL + // ----------------------------------------------------------------------- + + /** + * Écrit l'export SQL de la base dans un fichier. + * @param bool $avecIndex false : l'index de recherche est laissé vide, à reconstruire après restauration + * @return array{tables:int, lignes:int} + */ + public static function exporterBase(PDO $pdo, string $chemin, bool $avecIndex = true): array + { + $f = fopen($chemin, 'wb'); + if ($f === false) { + throw new RuntimeException('Le fichier de sauvegarde ne peut pas être écrit.'); + } + $base = (string) $pdo->query('SELECT DATABASE()')->fetchColumn(); + fwrite($f, "-- Sauvegarde Vivier\n-- Base : $base\n-- Date : " . date('Y-m-d H:i:s P') . "\n" + . '-- Serveur : ' . $pdo->getAttribute(PDO::ATTR_SERVER_VERSION) . "\n" + . "-- Restauration : importer ce fichier dans phpMyAdmin, sur une base vide ou à remplacer.\n" + . ($avecIndex ? '' : "-- L'index de recherche n'est pas inclus : après restauration, ouvrir Vivier, Comptes, « Réindexer tous les profils ».\n") + . "\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS = 0;\nSET SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO';\n\n"); + + $tables = $pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN); + $lignes = 0; + foreach ($tables as $table) { + $t = '`' . str_replace('`', '``', $table) . '`'; + $creation = $pdo->query("SHOW CREATE TABLE $t")->fetch(PDO::FETCH_NUM)[1]; + fwrite($f, "-- Table $table\nDROP TABLE IF EXISTS $t;\n$creation;\n\n"); + if (in_array($table, self::SANS_DONNEES, true) || (!$avecIndex && $table === 'index_recherche')) { + continue; + } + $req = $pdo->query("SELECT * FROM $t"); + $lot = []; + $colonnes = null; + $vider = static function () use (&$lot, &$colonnes, $f, $t): void { + if ($lot) { + fwrite($f, "INSERT INTO $t ($colonnes) VALUES\n" . implode(",\n", $lot) . ";\n"); + $lot = []; + } + }; + while ($l = $req->fetch(PDO::FETCH_ASSOC)) { + $colonnes ??= implode(', ', array_map(static fn ($c) => '`' . str_replace('`', '``', $c) . '`', array_keys($l))); + $lot[] = '(' . implode(', ', array_map( + static fn ($v) => $v === null ? 'NULL' : (is_int($v) ? (string) $v : $pdo->quote((string) $v)), + $l + )) . ')'; + $lignes++; + if (count($lot) >= 100) { + $vider(); + } + } + $vider(); + fwrite($f, "\n"); + } + fwrite($f, "SET FOREIGN_KEY_CHECKS = 1;\n-- Fin de la sauvegarde\n"); + fclose($f); + return ['tables' => count($tables), 'lignes' => $lignes]; + } + + // ----------------------------------------------------------------------- + // Exécution + // ----------------------------------------------------------------------- + + /** + * Fait une sauvegarde complète : export, zip, copie sur le serveur, email, purge des copies anciennes. + * Ne lève pas d'exception : un échec est enregistré, signalé par email, et renvoyé dans le résultat. + * + * @param string $origine planifiee ou manuelle + * @param bool $envoyer envoie le zip aux destinataires + * @return array{reussie:bool, fichier:?string, taille:?int, envoi:string, detail:string} + */ + public static function executer(string $origine, bool $envoyer, ?int $utilisateurId = null): array + { + $r = self::reglages(); + @set_time_limit(600); + try { + $pdo = db(); + $nom = 'vivier-base-' . date('Y-m-d-Hi'); + $dossier = self::dossier(); + for ($n = 2; is_file("$dossier/$nom.zip"); $n++) { + $nom = 'vivier-base-' . date('Y-m-d-Hi') . "-$n"; + } + $zip = "$dossier/$nom.zip"; + $sql = "$dossier/$nom.sql"; + + $remarques = []; + self::exporterBase($pdo, $sql); + self::zipper($sql, $zip, "$nom.sql"); + // Trop lourd pour un email : l'index de recherche, qui se reconstruit, est laissé de côté + if ($envoyer && filesize($zip) > $r['taille_max_email']) { + self::exporterBase($pdo, $sql, false); + self::zipper($sql, $zip, "$nom.sql"); + $remarques[] = 'Index de recherche non inclus : à reconstruire après restauration (Comptes, « Réindexer tous les profils »).'; + } + unlink($sql); + chmod($zip, 0600); + $taille = (int) filesize($zip); + $profils = (int) $pdo->query('SELECT COUNT(*) FROM candidats')->fetchColumn(); + + $envoi = 'non_demande'; + if ($envoyer) { + if (!$r['destinataires'] || $r['expediteur'] === '') { + $envoi = 'echec'; + $remarques[] = 'Email non envoyé : destinataires ou expéditeur absents de config/config.php.'; + } else { + $joindre = $taille <= $r['taille_max_email']; + $texte = "Sauvegarde de la base Vivier du " . date('d/m/Y à H:i') . ".\r\n\r\n" + . "Profils : $profils\r\nFichier : $nom.zip (" . self::taille($taille) . ")\r\n" + . 'Origine : ' . ($origine === 'planifiee' ? 'tâche planifiée' : 'lancement manuel') . "\r\n\r\n" + . ($joindre + ? "Le fichier est joint à ce message. Il contient les données des candidats : gardez-le à l'abri.\r\n" + : "Le fichier dépasse la taille admise pour un email. Il n'est pas joint.\r\nTéléchargez-le dans Vivier, menu Gestion, Sauvegarde.\r\n") + . ($remarques ? "\r\n" . implode("\r\n", $remarques) . "\r\n" : '') + . "\r\nLes CV, photos et pièces jointes ne sont pas dans cette sauvegarde : ils se téléchargent dans Vivier, menu Gestion, Sauvegarde.\r\n" + . "Restauration : importer le fichier .sql dans phpMyAdmin. La marche à suivre figure dans le README de Vivier.\r\n"; + $ok = self::envoyer($r['destinataires'], $r['expediteur'], 'Vivier : sauvegarde du ' . date('d/m/Y'), $texte, $joindre ? $zip : null, "$nom.zip"); + $envoi = !$ok ? 'echec' : ($joindre ? 'envoye' : 'sans_piece'); + if (!$ok) { + $remarques[] = 'Le serveur a refusé l\'envoi de l\'email.'; + } + } + } + + $detail = mb_substr(implode(' ', $remarques), 0, 500); + $pdo->prepare('INSERT INTO sauvegardes (origine, reussie, fichier, taille_octets, profils, envoi, detail, cree_par) VALUES (?, 1, ?, ?, ?, ?, ?, ?)') + ->execute([$origine, "$nom.zip", $taille, $profils, $envoi, $detail !== '' ? $detail : null, $utilisateurId]); + self::purger($pdo, $r['semaines_conservees']); + + // La sauvegarde existe sur le serveur, mais elle n'est pas partie : les destinataires doivent le savoir + if ($envoi === 'echec' && $r['destinataires'] && $r['expediteur'] !== '') { + self::alerter($r, "La sauvegarde du " . date('d/m/Y à H:i') . " est faite et gardée sur le serveur, mais son envoi par email a échoué."); + } + return ['reussie' => true, 'fichier' => "$nom.zip", 'taille' => $taille, 'envoi' => $envoi, 'detail' => $detail]; + } catch (Throwable $ex) { + $detail = mb_substr(get_class($ex) . ' : ' . $ex->getMessage(), 0, 500); + error_log('Vivier, sauvegarde : ' . $detail); + foreach ([$sql ?? null, $zip ?? null] as $reste) { + if ($reste !== null && is_file($reste)) { + @unlink($reste); + } + } + try { + db()->prepare('INSERT INTO sauvegardes (origine, reussie, envoi, detail, cree_par) VALUES (?, 0, ?, ?, ?)') + ->execute([$origine, 'echec', $detail, $utilisateurId]); + } catch (Throwable) { + // La base est injoignable : l'email d'alerte reste le seul signal + } + self::alerter($r, "La sauvegarde du " . date('d/m/Y à H:i') . " a échoué.\r\n\r\nErreur : $detail"); + return ['reussie' => false, 'fichier' => null, 'taille' => null, 'envoi' => 'echec', 'detail' => $detail]; + } + } + + /** Envoie un email de test aux destinataires, sans pièce jointe. */ + public static function tester(): bool + { + $r = self::reglages(); + if (!$r['destinataires'] || $r['expediteur'] === '') { + return false; + } + return self::envoyer($r['destinataires'], $r['expediteur'], 'Vivier : essai d\'envoi', + "Cet email vérifie que Vivier peut écrire à cette adresse.\r\n\r\nEnvoyé le " . date('d/m/Y à H:i') . ' depuis ' . $r['expediteur'] . ".\r\n" + . "S'il est arrivé dans les courriers indésirables, créez une règle pour cet expéditeur.\r\n", null, null); + } + + /** + * Envoie un message court au gestionnaire, hors sauvegarde (demande de compte). + * Destinataires : config « comptes » => « notifier », sinon l'adresse d'expédition de Vivier. + */ + public static function notifier(string $sujet, string $texte): bool + { + $r = self::reglages(); + if ($r['expediteur'] === '') { + return false; + } + $voulus = (array) ((config('comptes') ?? [])['notifier'] ?? []); + $destinataires = array_values(array_filter(array_map('trim', $voulus), static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL) !== false)); + return self::envoyer($destinataires ?: [$r['expediteur']], $r['expediteur'], $sujet, $texte, null, null); + } + + private static function alerter(array $r, string $texte): void + { + if ($r['destinataires'] && $r['expediteur'] !== '') { + self::envoyer($r['destinataires'], $r['expediteur'], 'Vivier : ÉCHEC de la sauvegarde du ' . date('d/m/Y'), + $texte . "\r\n\r\nOuvrez Vivier, menu Gestion, Sauvegarde, pour voir l'état et relancer.\r\n", null, null); + } + } + + private static function zipper(string $source, string $zip, string $nomInterne): void + { + $z = new ZipArchive(); + if ($z->open($zip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Le fichier zip ne peut pas être créé.'); + } + $z->addFile($source, $nomInterne); + if (!$z->close()) { + throw new RuntimeException('Le fichier zip ne peut pas être écrit.'); + } + } + + /** Supprime les copies plus anciennes que le nombre de semaines demandé. 0 : aucune suppression. */ + private static function purger(PDO $pdo, int $semaines): void + { + if ($semaines <= 0) { + return; + } + $req = $pdo->prepare('SELECT id, fichier FROM sauvegardes WHERE fichier IS NOT NULL AND cree_le < DATE_SUB(NOW(), INTERVAL ? WEEK)'); + $req->execute([$semaines]); + foreach ($req->fetchAll() as $s) { + self::supprimerFichier($pdo, $s); + } + } + + // ----------------------------------------------------------------------- + // Consultation + // ----------------------------------------------------------------------- + + public static function liste(int $limite = 200): array + { + return db()->query( + 'SELECT s.*, u.nom_affiche FROM sauvegardes s LEFT JOIN utilisateurs u ON u.id = s.cree_par ORDER BY s.cree_le DESC, s.id DESC LIMIT ' . $limite + )->fetchAll(); + } + + public static function ligne(int $id): ?array + { + $req = db()->prepare('SELECT * FROM sauvegardes WHERE id = ?'); + $req->execute([$id]); + return $req->fetch() ?: null; + } + + /** Chemin du zip d'une sauvegarde, ou null s'il n'existe plus. */ + public static function chemin(array $s): ?string + { + if (empty($s['fichier']) || !preg_match('/^vivier-base-[\d\-]+\.zip$/', $s['fichier'])) { + return null; + } + $chemin = self::dossier() . '/' . $s['fichier']; + return is_file($chemin) ? $chemin : null; + } + + public static function supprimerFichier(PDO $pdo, array $s): void + { + $chemin = self::chemin($s); + if ($chemin !== null) { + @unlink($chemin); + } + $pdo->prepare('UPDATE sauvegardes SET fichier = NULL WHERE id = ?')->execute([$s['id']]); + } + + /** + * État de la sauvegarde, pour le tableau de bord. + * @return array{derniere:?array, jours:?int, en_retard:bool, dernier_echec:?array} + */ + public static function etat(): array + { + $pdo = db(); + $derniere = $pdo->query('SELECT * FROM sauvegardes WHERE reussie = 1 ORDER BY cree_le DESC, id DESC LIMIT 1')->fetch() ?: null; + $jours = $derniere ? (int) (new DateTimeImmutable($derniere['cree_le']))->setTime(0, 0)->diff(new DateTimeImmutable('today'))->days : null; + $echec = $pdo->query('SELECT * FROM sauvegardes ORDER BY cree_le DESC, id DESC LIMIT 1')->fetch() ?: null; + $aSauver = (int) $pdo->query('SELECT COUNT(*) FROM candidats')->fetchColumn() > 0; + return [ + 'derniere' => $derniere, + 'jours' => $jours, + // Rien à sauvegarder : pas d'alerte sur une installation vide + 'en_retard' => $aSauver && ($jours === null || $jours > self::ALERTE_JOURS), + 'dernier_echec' => $echec && (!$echec['reussie'] || $echec['envoi'] === 'echec') ? $echec : null, + ]; + } + + // ----------------------------------------------------------------------- + // Archive des fichiers + // ----------------------------------------------------------------------- + + /** + * Zip des CV, documents et photos, avec un index qui dit à quel profil chaque fichier appartient. + * Les fichiers gardent leur chemin interne : remis dans stockage/, ils correspondent à la base restaurée. + * @return string chemin du zip, à supprimer après envoi + */ + public static function archiverFichiers(): string + { + @set_time_limit(600); + $pdo = db(); + $racine = dossier_stockage(); + $chemin = dossier_stockage('tmp') . '/fichiers-' . bin2hex(random_bytes(8)) . '.zip'; + $z = new ZipArchive(); + if ($z->open($chemin, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException(t('L\'archive n\'a pas pu être créée.')); + } + $index = fopen('php://temp', 'w+'); + fwrite($index, "\xEF\xBB\xBF"); + fputcsv($index, ['Chemin', 'Profil', 'Référence', 'Type', 'Nom du fichier', 'Ajouté le'], ';', '"', ''); + $ajouter = static function (string $relatif) use ($z, $racine): bool { + if (!is_file("$racine/$relatif")) { + return false; + } + $z->addFile("$racine/$relatif", $relatif); + // PDF, Word et JPEG sont déjà compressés : les stocker tels quels va plus vite + $z->setCompressionName($relatif, ZipArchive::CM_STORE); + return true; + }; + foreach ($pdo->query( + 'SELECT d.chemin, d.type, d.nom_fichier, d.ajoute_le, c.id, c.prenom, c.nom FROM documents d JOIN candidats c ON c.id = d.candidat_id ORDER BY c.id, d.id' + ) as $d) { + if ($ajouter($d['chemin'])) { + fputcsv($index, [$d['chemin'], $d['prenom'] . ' ' . $d['nom'], reference_profil($d), $d['type'], $d['nom_fichier'], $d['ajoute_le']], ';', '"', ''); + } + } + foreach ($pdo->query('SELECT id, prenom, nom, photo FROM candidats WHERE photo IS NOT NULL ORDER BY id') as $c) { + if ($ajouter($c['photo'])) { + fputcsv($index, [$c['photo'], $c['prenom'] . ' ' . $c['nom'], reference_profil($c), 'photo', 'photo.jpg', ''], ';', '"', ''); + } + } + rewind($index); + $z->addFromString('index.csv', (string) stream_get_contents($index)); + $z->addFromString('LISEZMOI.txt', + "Fichiers de Vivier, archive du " . date('d/m/Y H:i') . "\r\n\r\n" + . "docs/ et photos/ : les fichiers, sous leur nom interne.\r\n" + . "index.csv : le profil, le type et le nom d'origine de chaque fichier.\r\n\r\n" + . "Restauration : copier docs/ et photos/ dans le dossier stockage/ de Vivier, après avoir restauré la base du même jour.\r\n"); + if (!$z->close()) { + throw new RuntimeException(t('L\'archive n\'a pas pu être créée.')); + } + return $chemin; + } + + // ----------------------------------------------------------------------- + // Email + // ----------------------------------------------------------------------- + + /** + * Compose un email, avec une pièce jointe facultative. + * @return array{0:string,1:string,2:string} sujet encodé, corps, en-têtes + */ + public static function composer(string $expediteur, string $sujet, string $texte, ?string $piece, ?string $nomPiece): array + { + $sujetEncode = '=?UTF-8?B?' . base64_encode($sujet) . '?='; + $entetes = ['From: Vivier <' . $expediteur . '>', 'Reply-To: ' . $expediteur, 'MIME-Version: 1.0', 'X-Mailer: Vivier']; + if ($piece === null) { + $entetes[] = 'Content-Type: text/plain; charset=UTF-8'; + $entetes[] = 'Content-Transfer-Encoding: 8bit'; + return [$sujetEncode, $texte, implode("\r\n", $entetes)]; + } + $limite = 'vivier-' . bin2hex(random_bytes(12)); + $entetes[] = 'Content-Type: multipart/mixed; boundary="' . $limite . '"'; + $nom = preg_replace('/[^\w.\-]+/', '_', (string) $nomPiece); + $corps = "--$limite\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n$texte\r\n" + . "--$limite\r\nContent-Type: application/zip; name=\"$nom\"\r\nContent-Transfer-Encoding: base64\r\n" + . "Content-Disposition: attachment; filename=\"$nom\"\r\n\r\n" + . chunk_split(base64_encode((string) file_get_contents($piece)), 76, "\r\n") + . "--$limite--\r\n"; + return [$sujetEncode, $corps, implode("\r\n", $entetes)]; + } + + private static function envoyer(array $destinataires, string $expediteur, string $sujet, string $texte, ?string $piece, ?string $nomPiece): bool + { + if (self::$transport !== null) { + return (bool) (self::$transport)($destinataires, $sujet, $texte, $piece, $nomPiece); + } + [$sujetEncode, $corps, $entetes] = self::composer($expediteur, $sujet, $texte, $piece, $nomPiece); + return @mail(implode(', ', $destinataires), $sujetEncode, $corps, $entetes); + } + + public static function taille(int $octets): string + { + return $octets >= 1048576 ? number_format($octets / 1048576, 1, ',', '') . ' Mo' : max(1, (int) round($octets / 1024)) . ' Ko'; + } +} diff --git a/src/aide.php b/src/aide.php new file mode 100644 index 0000000..1aea276 --- /dev/null +++ b/src/aide.php @@ -0,0 +1,55 @@ + 'Guide d\'utilisation', 'securite' => 'Sécurité', 'administration' => 'Guide d\'administration']; + +/** + * Prépare un guide pour l'affichage. + * Le fichier lu est aide/..md. Sans fichier dans la langue demandée, le français est servi. + * + * @return array{html:string, chapitres:array, langue:string}|null null si le guide n'existe pas + */ +function aide(string $guide, string $langue, ?string $dossier = null): ?array +{ + $dossier ??= VIVIER_RACINE . '/aide'; + if (!isset(DOCUMENTS_AIDE[$guide]) || !preg_match('/^[a-z]{2}$/', $langue)) { + return null; + } + $fichier = "$dossier/$guide.$langue.md"; + if (!is_file($fichier)) { + $langue = 'fr'; + $fichier = "$dossier/$guide.fr.md"; + if (!is_file($fichier)) { + return null; + } + } + $html = markdown_vers_html((string) file_get_contents($fichier)); + + // Chaque titre reçoit une ancre : le sommaire et les liens directs s'y accrochent + $chapitres = []; + $vus = []; + $html = preg_replace_callback('#(.*?)#su', static function (array $m) use (&$chapitres, &$vus): string { + $texte = html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8'); + $base = trim(preg_replace('/[^a-z0-9]+/', '-', Recherche::normaliser($texte)) ?? '', '-') ?: 'section'; + $id = $base; + for ($n = 2; isset($vus[$id]); $n++) { + $id = "$base-$n"; + } + $vus[$id] = true; + if ($m[1] === '2') { + $chapitres[] = ['id' => $id, 'titre' => $texte]; + } + return '' . $m[2] . ''; + }, $html) ?? $html; + + // Un tableau large défile dans son cadre au lieu d'élargir la page + $html = str_replace(['', '
'], ['
', '
'], $html); + + return ['html' => $html, 'chapitres' => $chapitres, 'langue' => $langue]; +} diff --git a/src/besoins.php b/src/besoins.php new file mode 100644 index 0000000..b03b3f2 --- /dev/null +++ b/src/besoins.php @@ -0,0 +1,327 @@ + \'ecarte\') AS proposes + FROM besoins b JOIN utilisateurs u ON u.id = b.cree_par' + . (isset(ETATS_BESOIN[$statut]) ? ' WHERE b.statut = ?' : '') + . " ORDER BY FIELD(b.statut, 'ouvert', 'pourvu', 'ferme'), (b.date_debut IS NULL), b.date_debut, b.id DESC"; + $req = db()->prepare($sql); + $req->execute(isset(ETATS_BESOIN[$statut]) ? [$statut] : []); + return $req->fetchAll(); +} + +/** Besoins ouverts, pour les listes de choix. */ +function besoins_ouverts(): array +{ + return db()->query("SELECT id, client, intitule FROM besoins WHERE statut = 'ouvert' ORDER BY client, intitule")->fetchAll(); +} + +function besoin(int $id): ?array +{ + $req = db()->prepare('SELECT b.*, u.nom_affiche FROM besoins b JOIN utilisateurs u ON u.id = b.cree_par WHERE b.id = ?'); + $req->execute([$id]); + $b = $req->fetch(); + if (!$b) { + return null; + } + $req = db()->prepare( + 'SELECT bc.etat, bc.note, bc.ajoute_le, u.nom_affiche, c.id, c.prenom, c.nom, c.titre, c.seniorite, c.annees_experience, c.statut, c.photo, + c.archive_le, c.tjm, c.disponible_le + FROM besoin_candidat bc JOIN candidats c ON c.id = bc.candidat_id JOIN utilisateurs u ON u.id = bc.ajoute_par + WHERE bc.besoin_id = ? AND c.supprime_le IS NULL ORDER BY FIELD(bc.etat, \'retenu\', \'presente\', \'propose\', \'ecarte\'), bc.ajoute_le' + ); + $req->execute([$id]); + $b['propositions'] = traduire_titres($req->fetchAll()); + return $b; +} + +/** + * Crée ou modifie un besoin. + * @return int identifiant du besoin + * @throws InvalidArgumentException + */ +function enregistrer_besoin(?array $ancien, array $saisie): int +{ + $texte = static fn (string $cle, int $max): ?string => ($v = trim((string) ($saisie[$cle] ?? ''))) === '' ? null : mb_substr($v, 0, $max); + $client = $texte('client', 200); + $intitule = $texte('intitule', 200); + if ($client === null || $intitule === null) { + throw new InvalidArgumentException(t('Le client et l\'intitulé sont obligatoires.')); + } + $seniorite = (string) ($saisie['seniorite_min'] ?? ''); + $teletravail = (string) ($saisie['teletravail'] ?? ''); + $tjm = trim((string) ($saisie['tjm_max'] ?? '')); + if ($tjm !== '' && (!ctype_digit($tjm) || strlen($tjm) > 5)) { + throw new InvalidArgumentException(t('%s : nombre entier attendu, sans espace ni symbole.', t('Budget journalier'))); + } + $debut = trim((string) ($saisie['date_debut'] ?? '')); + $col = [ + 'client' => $client, + 'intitule' => $intitule, + 'description' => $texte('description', 10000), + 'technologies' => mb_substr(implode(', ', liste_saisie($saisie['technologies'] ?? '')), 0, 1000) ?: null, + 'seniorite_min' => isset(SENIORITES[$seniorite]) ? $seniorite : null, + 'langues' => mb_substr(implode(', ', liste_saisie($saisie['langues'] ?? '')), 0, 300) ?: null, + 'lieu' => $texte('lieu', 150), + 'teletravail' => isset(TELETRAVAIL[$teletravail]) ? $teletravail : null, + 'tjm_max' => $tjm === '' ? null : (int) $tjm, + 'date_debut' => $debut === '' ? null : date_saisie($debut, 'Début'), + ]; + if ($ancien === null) { + $col['cree_par'] = utilisateur()['id']; + db()->prepare('INSERT INTO besoins (' . implode(', ', array_keys($col)) . ') VALUES (' . implode(', ', array_fill(0, count($col), '?')) . ')') + ->execute(array_values($col)); + $id = (int) db()->lastInsertId(); + journaliser(null, 'besoin', 'creation', null, $id . '|' . $client . ' : ' . $intitule); + return $id; + } + db()->prepare('UPDATE besoins SET ' . implode(' = ?, ', array_keys($col)) . ' = ?, modifie_le = NOW() WHERE id = ?') + ->execute([...array_values($col), $ancien['id']]); + journaliser(null, 'besoin', 'modification', null, $ancien['id'] . '|' . $client . ' : ' . $intitule); + return (int) $ancien['id']; +} + +function changer_statut_besoin(array $b, string $statut): void +{ + if (!isset(ETATS_BESOIN[$statut]) || $statut === $b['statut']) { + return; + } + db()->prepare('UPDATE besoins SET statut = ?, modifie_le = NOW() WHERE id = ?')->execute([$statut, $b['id']]); + journaliser(null, 'besoin', 'statut', null, $b['id'] . '|' . $b['client'] . ' : ' . $b['intitule'] . '|' . $statut); +} + +function supprimer_besoin(array $b): void +{ + db()->prepare('DELETE FROM besoins WHERE id = ?')->execute([$b['id']]); + journaliser(null, 'besoin', 'suppression', $b['id'] . '|' . $b['client'] . ' : ' . $b['intitule'], null); +} + +/** Propose un profil pour un besoin. Sans effet s'il l'est déjà. */ +function proposer_candidat(array $b, int $candidatId): void +{ + $req = db()->prepare('INSERT IGNORE INTO besoin_candidat (besoin_id, candidat_id, ajoute_par) VALUES (?, ?, ?)'); + $req->execute([$b['id'], $candidatId, utilisateur()['id']]); + if ($req->rowCount() > 0) { + journaliser($candidatId, 'besoin', 'propose', null, $b['id'] . '|' . $b['client'] . ' : ' . $b['intitule'] . '|propose'); + } +} + +function changer_proposition(array $b, int $candidatId, string $etat, string $note): void +{ + if (!isset(ETATS_PROPOSITION[$etat])) { + return; + } + $req = db()->prepare('SELECT etat FROM besoin_candidat WHERE besoin_id = ? AND candidat_id = ?'); + $req->execute([$b['id'], $candidatId]); + $ancien = $req->fetchColumn(); + if ($ancien === false) { + return; + } + $note = mb_substr(trim($note), 0, 500); + db()->prepare('UPDATE besoin_candidat SET etat = ?, note = ? WHERE besoin_id = ? AND candidat_id = ?') + ->execute([$etat, $note !== '' ? $note : null, $b['id'], $candidatId]); + if ($ancien !== $etat) { + journaliser($candidatId, 'besoin', 'etat', null, $b['id'] . '|' . $b['client'] . ' : ' . $b['intitule'] . '|' . $etat); + } +} + +function retirer_proposition(array $b, int $candidatId): void +{ + $req = db()->prepare('DELETE FROM besoin_candidat WHERE besoin_id = ? AND candidat_id = ?'); + $req->execute([$b['id'], $candidatId]); + if ($req->rowCount() > 0) { + journaliser($candidatId, 'besoin', 'retire', $b['id'] . '|' . $b['client'] . ' : ' . $b['intitule'], null); + } +} + +// --------------------------------------------------------------------------- +// Rapprochement +// --------------------------------------------------------------------------- + +/** Barème du rapprochement : points au plus par critère. Total 100. */ +const BAREME_RAPPROCHEMENT = ['technologies' => 55, 'intitule' => 15, 'seniorite' => 10, 'langues' => 10, 'disponibilite' => 5, 'tjm' => 5]; + +/** + * Note d'un profil pour un besoin. Fonction pure : aucune lecture en base. + * + * @param array $b besoin : technologies, intitule, seniorite_min, langues, date_debut, tjm_max + * @param array $p profil : termes (termes d'index => true), technologies (nom normalisé => années ou null), + * seniorite, langues (noms normalisés), disponible_le, tjm + * @return array{score:int, trouvees:array, manquantes:string[], alertes:string[], pertinent:bool} + */ +function noter_rapprochement(array $b, array $p): array +{ + $bareme = BAREME_RAPPROCHEMENT; + $termesDe = static fn (string $t): array => array_values(array_filter(Recherche::termes($t, false), static fn ($x) => !Recherche::motVide($x))); + $points = 0.0; + $trouvees = []; + $manquantes = []; + $alertes = []; + + // Technologies demandées : présentes sur la fiche, avec un bonus selon les années de pratique (plafonné à 5 ans) + $demandees = liste_saisie($b['technologies'] ?? ''); + $partTechno = null; + if ($demandees) { + $somme = 0.0; + foreach ($demandees as $nom) { + $cle = trim(Recherche::normaliser($nom)); + $annees = $p['technologies'][$cle] ?? null; + $termes = $termesDe($nom); + $present = array_key_exists($cle, $p['technologies']) + || ($termes && count(array_filter($termes, static fn ($x) => isset($p['termes'][$x]))) === count($termes)); + if ($present) { + $somme += 0.6 + 0.4 * min((float) $annees, 5) / 5; + $trouvees[] = ['nom' => $nom, 'annees' => $annees]; + } else { + $manquantes[] = $nom; + } + } + $partTechno = $somme / count($demandees); + $points += $bareme['technologies'] * $partTechno; + } + + // Mots de l'intitulé retrouvés sur la fiche + $mots = $termesDe((string) ($b['intitule'] ?? '')); + $partIntitule = $mots ? count(array_filter($mots, static fn ($x) => isset($p['termes'][$x]))) / count($mots) : 0.0; + // Sans technologie demandée, l'intitulé porte aussi leurs points + $points += ($bareme['intitule'] + ($demandees ? 0 : $bareme['technologies'])) * $partIntitule; + + // Séniorité : atteinte, ou un niveau en dessous + $rangs = array_flip(array_keys(SENIORITES)); + if (empty($b['seniorite_min'])) { + $points += $bareme['seniorite']; + } else { + $ecart = $rangs[$p['seniorite']] - $rangs[$b['seniorite_min']]; + $points += $ecart >= 0 ? $bareme['seniorite'] : ($ecart === -1 ? $bareme['seniorite'] * 0.4 : 0); + if ($ecart < 0) { + $alertes[] = t('Séniorité inférieure à la demande'); + } + } + + // Langues demandées + $langues = liste_saisie($b['langues'] ?? ''); + if (!$langues) { + $points += $bareme['langues']; + } else { + $absentes = array_values(array_filter($langues, static fn ($l) => !in_array(trim(Recherche::normaliser($l)), $p['langues'], true))); + $points += $bareme['langues'] * (1 - count($absentes) / count($langues)); + if ($absentes) { + $alertes[] = t('Langue absente de la fiche : %s', implode(', ', $absentes)); + } + } + + // Disponibilité : inconnue, la moitié des points + if (empty($b['date_debut'])) { + $points += $bareme['disponibilite']; + } elseif (empty($p['disponible_le'])) { + $points += $bareme['disponibilite'] / 2; + } elseif ($p['disponible_le'] <= $b['date_debut']) { + $points += $bareme['disponibilite']; + } else { + $alertes[] = t('Disponible après le début de la mission'); + } + + // Tarif : inconnu, la moitié des points + if (empty($b['tjm_max'])) { + $points += $bareme['tjm']; + } elseif (empty($p['tjm'])) { + $points += $bareme['tjm'] / 2; + } elseif ((int) $p['tjm'] <= (int) $b['tjm_max']) { + $points += $bareme['tjm']; + } else { + $points += (int) $p['tjm'] <= (int) $b['tjm_max'] * 1.1 ? $bareme['tjm'] * 0.4 : 0; + $alertes[] = t('TJM souhaité au-dessus du budget'); + } + + return [ + 'score' => (int) round($points), + 'trouvees' => $trouvees, + 'manquantes' => $manquantes, + 'alertes' => $alertes, + // Un profil sans aucune technologie demandée ni mot de l'intitulé n'a pas sa place dans la liste + 'pertinent' => ($partTechno ?? 0) > 0 || $partIntitule > 0, + ]; +} + +/** + * Profils du vivier qui répondent le mieux à un besoin, les mieux notés d'abord. + * Exclus : profils archivés, non retenus, déjà proposés pour ce besoin. + */ +function rapprochement(array $b, int $limite = 30): array +{ + $pdo = db(); + $profils = []; + $req = $pdo->prepare( + "SELECT c.id, c.prenom, c.nom, c.titre, c.seniorite, c.annees_experience, c.statut, c.photo, c.disponibilite_code, + IF(c.disponibilite_code = 'immediate', CURDATE(), c.disponible_le) AS disponible_le, c.disponibilite, c.tjm + FROM candidats c + WHERE c.archive_le IS NULL AND c.supprime_le IS NULL AND c.statut <> 'non_retenu' + AND NOT EXISTS (SELECT 1 FROM besoin_candidat bc WHERE bc.besoin_id = ? AND bc.candidat_id = c.id)" + ); + $req->execute([$b['id']]); + foreach ($req as $c) { + $profils[(int) $c['id']] = $c + ['termes' => [], 'technologies' => [], 'langues' => []]; + } + if (!$profils) { + return []; + } + + // Seuls les termes utiles au besoin sont lus dans l'index + $termes = []; + foreach ([...liste_saisie($b['technologies'] ?? ''), (string) $b['intitule']] as $texte) { + foreach (Recherche::termes($texte, false) as $t) { + $termes[$t] = true; + } + } + if ($termes) { + $req = $pdo->prepare('SELECT candidat_id, terme FROM index_recherche WHERE terme IN (' . implode(', ', array_fill(0, count($termes), '?')) . ')'); + $req->execute(array_map('strval', array_keys($termes))); + foreach ($req as $l) { + if (isset($profils[$l['candidat_id']])) { + $profils[$l['candidat_id']]['termes'][$l['terme']] = true; + } + } + } + foreach ($pdo->query('SELECT ct.candidat_id, t.nom, ct.annees FROM candidat_technologie ct JOIN technologies t ON t.id = ct.technologie_id') as $l) { + if (isset($profils[$l['candidat_id']])) { + $profils[$l['candidat_id']]['technologies'][trim(Recherche::normaliser($l['nom']))] = $l['annees'] === null ? null : (float) $l['annees']; + } + } + foreach ($pdo->query('SELECT candidat_id, langue FROM langues') as $l) { + if (isset($profils[$l['candidat_id']])) { + $profils[$l['candidat_id']]['langues'][] = trim(Recherche::normaliser($l['langue'])); + } + } + + $resultats = []; + foreach ($profils as $p) { + $note = noter_rapprochement($b, $p); + if ($note['pertinent']) { + unset($p['termes'], $p['technologies'], $p['langues']); + $resultats[] = $p + $note; + } + } + usort($resultats, static fn ($x, $y) => [$y['score'], $x['nom']] <=> [$x['score'], $y['nom']]); + return traduire_titres(array_slice($resultats, 0, $limite)); +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..50eb18e --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,451 @@ + 'Reçu', + 'en_cours' => 'En cours', + 'entretiens' => 'Entretiens en cours', + 'valide' => 'Validé', + 'embauche' => 'Embauché', + 'non_retenu' => 'Non retenu', +]; + +// États retirés en version 1.4. Le journal en garde la trace : ils restent lisibles et comptent comme « entretiens ». +const ANCIENS_STATUTS = [ + 'entretien_1_valide' => 'Premier entretien validé', + 'entretien_2_valide' => 'Second entretien validé', +]; + +const SENIORITES = [ + 'junior' => 'Junior', + 'intermediaire' => 'Intermédiaire', + 'confirme' => 'Confirmé', + 'expert' => 'Expert', +]; + +const ROLES = [ + 'admin' => 'Gestionnaire des comptes', + 'recruteur' => 'Utilisateur', +]; + +const TYPES_MOT = [ + 'role' => 'Métiers', + 'specialite' => 'Spécialités', + 'secteur' => 'Secteurs', + 'mot_cle' => 'Mots-clés', +]; + +const NIVEAUX_ETUDES = ['bac' => 'Bac', 'bac+2' => 'Bac+2', 'bac+3' => 'Bac+3', 'bac+4' => 'Bac+4', 'bac+5' => 'Bac+5', 'doctorat' => 'Doctorat', 'autre' => 'Autre']; + +// Session fermée après ce délai sans activité, en secondes +const DUREE_INACTIVITE = 2 * 3600; + +// Blocage de la connexion, durée des sessions, double authentification : voir src/securite.php + +const LONGUEUR_MOT_DE_PASSE = 12; + +// Corbeille : un profil supprimé est effacé pour de bon après ce nombre de jours +const CORBEILLE_JOURS = 30; + +const ACTIONS_JOURNAL = [ + 'creation' => 'Création', 'modification' => 'Modification', 'changement_statut' => 'Changement de statut', + 'ajout_document' => 'Ajout de document', 'suppression_document' => 'Suppression de document', + 'commentaire' => 'Commentaire', 'export' => 'Export', 'suppression' => 'Suppression', + 'archivage' => 'Archivage', 'desarchivage' => 'Désarchivage', 'entretien' => 'Entretien', 'relance' => 'Relance', + 'consentement' => 'Consentement', 'besoin' => 'Besoin client', 'sauvegarde' => 'Sauvegarde', + 'corbeille' => 'Mise à la corbeille', 'restauration' => 'Restauration', 'compte' => 'Compte', + 'securite' => 'Sécurité', +]; + +/** + * Disponibilité d'un profil : une valeur de cette liste, et une date pour « Date précise ». + * Pour « Sous N mois », Vivier garde aussi une date estimée (jour de la saisie + N mois), qui sert au rapprochement. + */ +const DISPONIBILITES = [ + 'immediate' => 'Immédiate', + '1_mois' => 'Sous 1 mois', + '2_mois' => 'Sous 2 mois', + '3_mois' => 'Sous 3 mois', + 'date' => 'Date précise', + 'a_preciser' => 'À préciser', +]; + +const TYPES_ENTRETIEN = [ + 'telephone' => 'Téléphone', 'visio' => 'Visioconférence', 'presentiel' => 'Sur place', + 'technique' => 'Entretien technique', 'client' => 'Entretien client', +]; + +const AVIS_ENTRETIEN = ['favorable' => 'Favorable', 'reserve' => 'Réservé', 'defavorable' => 'Défavorable']; + +const TELETRAVAIL = ['non' => 'Sur site', 'partiel' => 'Télétravail partiel', 'complet' => 'Télétravail complet']; + +const ETATS_BESOIN = ['ouvert' => 'Ouvert', 'pourvu' => 'Pourvu', 'ferme' => 'Fermé']; + +const ETATS_PROPOSITION = ['propose' => 'À présenter', 'presente' => 'Présenté au client', 'retenu' => 'Retenu par le client', 'ecarte' => 'Écarté']; + +// Nombre de CV au plus dans un import en lot +const LOT_MAX = 20; + +const TYPES_EXPERIENCE = ['emploi' => 'emploi', 'mission' => 'mission', 'alternance' => 'alternance', 'stage' => 'stage', 'independant' => 'indépendant']; + +const TYPES_DOCUMENT = [ + 'cv_origine' => 'CV d\'origine', + 'rapport' => 'Rapport', + 'cv_itclub' => 'CV IT Club', + 'cv_anonyme' => 'CV anonymisé', + 'autre' => 'Autre', +]; + +const EXTENSIONS_AUTORISEES = ['pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt', 'png', 'jpg', 'jpeg', 'xlsx']; + +$fichierConfig = VIVIER_RACINE . '/config/config.php'; +if (!is_file($fichierConfig)) { + http_response_code(500); + exit(t('Configuration absente : copiez config/config.exemple.php vers config/config.php.')); +} +$GLOBALS['VIVIER_CONFIG'] = require $fichierConfig; +date_default_timezone_set($GLOBALS['VIVIER_CONFIG']['fuseau'] ?? 'Europe/Luxembourg'); + +// Erreur imprévue : elle est notée dans stockage/journal/erreurs.log (page Gestion, Erreurs) et dans le journal +// du serveur. L'écran reste sobre et donne une référence, que le gestionnaire retrouve dans la page Erreurs. +set_exception_handler(static function (Throwable $ex): void { + $ref = consigner_erreur(get_class($ex), $ex->getMessage(), $ex->getFile(), $ex->getLine()); + afficher_erreur($ref); +}); +// Erreur fatale (mémoire, délai dépassé) : aucune exception n'est levée, on la rattrape à l'arrêt du script +register_shutdown_function(static function (): void { + $err = error_get_last(); + if ($err !== null && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { + $ref = consigner_erreur('Erreur fatale', (string) $err['message'], (string) $err['file'], (int) $err['line']); + afficher_erreur($ref); + } +}); + +require __DIR__ . '/erreurs.php'; +require __DIR__ . '/securite.php'; +require __DIR__ . '/pays.php'; +require __DIR__ . '/Import.php'; +require __DIR__ . '/ExportWord.php'; +require __DIR__ . '/Recherche.php'; +require __DIR__ . '/markdown.php'; +require __DIR__ . '/extraction.php'; +require __DIR__ . '/Claude.php'; +require __DIR__ . '/Photo.php'; +require __DIR__ . '/depot.php'; +require __DIR__ . '/suivi.php'; +require __DIR__ . '/besoins.php'; +require __DIR__ . '/Lot.php'; +require __DIR__ . '/Sauvegarde.php'; +require __DIR__ . '/aide.php'; +require __DIR__ . '/traduction.php'; +require __DIR__ . '/europass.php'; +require __DIR__ . '/ExportEuropass.php'; + +function config(string $cle): mixed +{ + return $GLOBALS['VIVIER_CONFIG'][$cle] ?? null; +} + +function db(): PDO +{ + static $pdo = null; + if ($pdo === null) { + $c = config('db'); + $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $c['hote'], $c['port'], $c['base']); + $pdo = new PDO($dsn, $c['utilisateur'], $c['mot_de_passe'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + // Les dates écrites par NOW() suivent le fuseau de l'application + $pdo->exec("SET time_zone = '" . date('P') . "'"); + } + return $pdo; +} + +/** Échappe une valeur pour l'afficher dans du HTML. */ +function e(mixed $v): string +{ + return htmlspecialchars((string) ($v ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function url(string $page, array $params = []): string +{ + return 'index.php?' . http_build_query(['p' => $page] + $params); +} + +function rediriger(string $page, array $params = []): never +{ + header('Location: ' . url($page, $params)); + exit; +} + +/** Retour sur la fiche d'un profil, à l'onglet qui contient la section $ancre (voir vues/profil.php). */ +function rediriger_fiche(int $id, string $ancre = ''): never +{ + header('Location: ' . url('profil', ['id' => $id]) . ($ancre !== '' ? '#' . $ancre : '')); + exit; +} + +function flash(string $message, string $type = 'info'): void +{ + $_SESSION['flash'][] = ['type' => $type, 'message' => $message]; +} + +function vue(string $nom, array $donnees = []): void +{ + extract($donnees, EXTR_SKIP); + $page = (string) ($_GET['p'] ?? 'kanban'); + ob_start(); + require VIVIER_RACINE . '/vues/' . $nom . '.php'; + $contenu = ob_get_clean(); + $flashs = $_SESSION['flash'] ?? []; + unset($_SESSION['flash']); + require VIVIER_RACINE . '/vues/gabarit.php'; +} + +/** Position d'un statut dans le parcours : 1 (reçu) à 5 (embauché), 0 pour un refus. */ +const ECHELONS = ['recu' => 1, 'en_cours' => 2, 'entretiens' => 3, 'valide' => 4, 'embauche' => 5, 'non_retenu' => 0]; + +/** + * Échelle d'avancement : cinq barres empilées, remplies depuis le bas jusqu'à l'étape atteinte. + * Reprend les barres du logo IT Club. Purement visuelle : le libellé du statut l'accompagne toujours. + */ +function echelle(string $statut, string $taille = ''): string +{ + $n = ECHELONS[$statut] ?? 0; + $barres = ''; + for ($i = 5; $i >= 1; $i--) { + $barres .= ''; + } + return ''; +} + +/** + * Portrait du candidat, ou ses initiales s'il n'a pas de photo. + * @param array $c doit contenir id, prenom, nom, photo + */ +function avatar(array $c, string $taille = ''): string +{ + $classe = 'avatar' . ($taille !== '' ? ' avatar-' . $taille : ''); + $initiales = mb_strtoupper(mb_substr((string) $c['prenom'], 0, 1) . mb_substr((string) $c['nom'], 0, 1)); + if (!empty($c['photo'])) { + return ''; + } + return ''; +} + +/** Montant en euros, sans décimale : « 550 € ». */ +function euros(mixed $n): string +{ + return $n === null || $n === '' ? '' : number_format((float) $n, 0, ',', "\u{202F}") . "\u{00A0}€"; +} + +/** Libellé d'un statut, y compris un ancien code resté dans le journal. */ +function libelle_statut(string $code): string +{ + return isset(STATUTS[$code]) ? t(STATUTS[$code]) : (isset(ANCIENS_STATUTS[$code]) ? t(ANCIENS_STATUTS[$code]) : $code); +} + +function date_fr(?string $date, bool $heure = false): string +{ + if (!$date) { + return ''; + } + return (new DateTimeImmutable($date))->format($heure ? 'd/m/Y H:i' : 'd/m/Y'); +} + +// --------------------------------------------------------------------------- +// Session, authentification, CSRF +// --------------------------------------------------------------------------- + +function demarrer_session(): void +{ + // HTTPS obligatoire en production : une page demandée en HTTP est redirigée, un formulaire envoyé en HTTP est refusé. + // Le .htaccess fait déjà la redirection ; ce contrôle couvre un hébergement qui l'ignorerait. + if (config('https') && !est_https() && PHP_SAPI !== 'cli') { + $hote = preg_replace('/[^a-zA-Z0-9.\-:\[\]]/', '', (string) ($_SERVER['HTTP_HOST'] ?? '')); + if ($_SERVER['REQUEST_METHOD'] === 'GET' && $hote !== '') { + header('Location: https://' . $hote . ($_SERVER['REQUEST_URI'] ?? '/'), true, 301); + exit; + } + http_response_code(403); + exit(t('Vivier ne s\'utilise qu\'en HTTPS.')); + } + if (est_https()) { + header('Strict-Transport-Security: max-age=31536000'); + } + + session_name('vivier'); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => (bool) config('https'), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + + // Fermeture après inactivité, et au plus tard DUREE_SESSION_MAX après la connexion + if (!empty($_SESSION['uid']) && (time() - (int) ($_SESSION['vu'] ?? 0) > DUREE_INACTIVITE || time() - (int) ($_SESSION['debut'] ?? 0) > DUREE_SESSION_MAX)) { + $_SESSION = ['session_expiree' => true]; + session_regenerate_id(true); + } + $_SESSION['vu'] = time(); + + // Langue de l'interface : celle du compte, sinon le dernier choix fait sur ce navigateur, sinon celle du navigateur + $u = !empty($_SESSION['uid']) ? utilisateur() : null; + langue_courante($u['langue'] ?? (isset(LANGUES_INTERFACE[$_COOKIE['vivier_langue'] ?? '']) ? $_COOKIE['vivier_langue'] : langue_du_navigateur())); + + header('X-Content-Type-Options: nosniff'); + // Aucune page de Vivier ne doit figurer dans un moteur de recherche, la page de demande de compte comprise + header('X-Robots-Tag: noindex, nofollow, noarchive'); + header('X-Frame-Options: DENY'); + header('Referrer-Policy: same-origin'); + header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"); +} + +function jeton_csrf(): string +{ + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf']; +} + +function champ_csrf(): string +{ + return ''; +} + +function verifier_csrf(): void +{ + $recu = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!is_string($recu) || !hash_equals(jeton_csrf(), $recu)) { + http_response_code(403); + exit(t('Session expirée ou requête invalide. Rechargez la page.')); + } +} + +/** + * Utilisateur connecté et actif, relu en base à chaque requête. + * Une session dont la version ne correspond plus à celle du compte est fermée : mot de passe changé, + * double authentification remise à zéro, compte désactivé ou « Déconnecter mes autres sessions ». + */ +function utilisateur(bool $relire = false): ?array +{ + static $u = false; + if ($u === false || $relire) { + $u = null; + if (!empty($_SESSION['uid'])) { + $req = db()->prepare('SELECT id, identifiant, nom_affiche, role, langue, session_version, totp_active_le, codes_secours FROM utilisateurs WHERE id = ? AND actif = 1'); + $req->execute([$_SESSION['uid']]); + $u = $req->fetch() ?: null; + if ($u !== null && (int) $u['session_version'] !== (int) ($_SESSION['sv'] ?? 0)) { + $u = null; + } + if ($u === null) { + $_SESSION = ['session_expiree' => true]; + } + } + } + return $u; +} + +function exiger_connexion(): array +{ + $u = utilisateur(); + if ($u === null) { + // Une requête du script (Kanban, traduction, lot) reçoit une réponse lisible, pas la page de connexion + if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'fetch') { + http_response_code(401); + header('Content-Type: application/json; charset=utf-8'); + exit(json_encode(['ok' => false, 'erreur' => t('Votre session est terminée. Reconnectez-vous.')], JSON_UNESCAPED_UNICODE)); + } + // La page demandée s'ouvrira après la connexion + if ($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['p'])) { + $_SESSION['retour'] = 'index.php?' . http_build_query(array_filter($_GET, 'is_string')); + } + rediriger('connexion'); + } + return $u; +} + +function peut_ecrire(): bool +{ + $u = utilisateur(); + // Un seul niveau d'accès aux profils : tout compte actif lit et écrit. + // La fonction reste le point unique à modifier si un accès en lecture seule revient. + return $u !== null; +} + +function exiger_ecriture(): array +{ + $u = exiger_connexion(); + if (!peut_ecrire()) { + http_response_code(403); + exit(t('Action non autorisée pour ce compte.')); + } + return $u; +} + +function est_admin(): bool +{ + return (utilisateur()['role'] ?? '') === 'admin'; +} + +function exiger_admin(): array +{ + $u = exiger_connexion(); + if ($u['role'] !== 'admin') { + http_response_code(403); + exit(t('Page réservée au gestionnaire des comptes.')); + } + return $u; +} + +// --------------------------------------------------------------------------- +// Fichiers +// --------------------------------------------------------------------------- + +function dossier_stockage(string $sous = ''): string +{ + $d = rtrim((string) config('stockage'), '/') . ($sous !== '' ? '/' . $sous : ''); + if (!is_dir($d) && !mkdir($d, 0700, true) && !is_dir($d)) { + throw new RuntimeException(t('Dossier de stockage inaccessible.')); + } + return $d; +} + +/** + * Contrôle un fichier chargé et renvoie ses caractéristiques. + * @param array $f une entrée de $_FILES + * @return array{tmp:string,nom:string,ext:string,mime:string,taille:int} + */ +function controler_fichier(array $f, array $extensions = EXTENSIONS_AUTORISEES): array +{ + if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($f['tmp_name'])) { + throw new RuntimeException(t('Le chargement du fichier a échoué.')); + } + if ($f['size'] > (int) config('taille_max')) { + throw new RuntimeException(t('Fichier trop volumineux (maximum %d Mo).', (int) round(config('taille_max') / 1048576))); + } + $nom = basename((string) $f['name']); + $ext = strtolower(pathinfo($nom, PATHINFO_EXTENSION)); + if (!in_array($ext, $extensions, true)) { + throw new RuntimeException(t('Format non accepté : .%s. Formats acceptés : %s.', $ext, implode(', ', $extensions))); + } + $mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']) ?: 'application/octet-stream'; + return ['tmp' => $f['tmp_name'], 'nom' => $nom, 'ext' => $ext, 'mime' => $mime, 'taille' => (int) $f['size']]; +} + +function fichier_fourni(?array $f): bool +{ + return $f !== null && ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE; +} diff --git a/src/depot.php b/src/depot.php new file mode 100644 index 0000000..0cfe7ad --- /dev/null +++ b/src/depot.php @@ -0,0 +1,1053 @@ +prepare( + 'INSERT INTO historique (candidat_id, utilisateur_id, action, champ, ancienne_valeur, nouvelle_valeur) VALUES (?, ?, ?, ?, ?, ?)' + ); + $req->execute([$candidatId, $auteurId ?? utilisateur()['id'], $action, $champ, $ancien, $nouveau]); +} + +/** Candidats non archivés regroupés par statut, avec leurs trois technologies principales. */ +function candidats_kanban(): array +{ + $colonnes = array_fill_keys(array_keys(STATUTS), []); + $lignes = db()->query( + 'SELECT c.id, c.prenom, c.nom, c.titre, c.seniorite, c.annees_experience, c.statut, c.motif_refus, c.cree_le, c.photo, c.hors_ue, c.permis_expire_le, + (SELECT MIN(e.date_entretien) FROM entretiens e WHERE e.candidat_id = c.id AND e.date_entretien >= CURDATE()) AS prochain_entretien, + (SELECT COUNT(*) FROM relances r WHERE r.candidat_id = c.id AND r.fait_le IS NULL AND r.echeance <= CURDATE()) AS relances_dues + FROM candidats c WHERE c.archive_le IS NULL AND c.supprime_le IS NULL ORDER BY c.position_kanban, c.id' + )->fetchAll(); + $lignes = traduire_titres($lignes); + + $technos = []; + $req = db()->query( + 'SELECT ct.candidat_id, t.nom FROM candidat_technologie ct + JOIN technologies t ON t.id = ct.technologie_id + ORDER BY ct.candidat_id, ct.annees DESC, t.nom' + ); + foreach ($req as $l) { + if (count($technos[$l['candidat_id']] ?? []) < 3) { + $technos[$l['candidat_id']][] = $l['nom']; + } + } + + foreach ($lignes as $c) { + $c['technologies'] = $technos[$c['id']] ?? []; + $colonnes[$c['statut']][] = $c; + } + return $colonnes; +} + +/** + * Fiche complète d'un profil. Un profil à la corbeille est introuvable pour toute l'application : + * seule la page Corbeille le lit, avec $avecCorbeille. + */ +function candidat(int $id, bool $avecCorbeille = false): ?array +{ + $pdo = db(); + $req = $pdo->prepare('SELECT * FROM candidats WHERE id = ?'); + $req->execute([$id]); + $c = $req->fetch(); + if (!$c || ($c['supprime_le'] !== null && !$avecCorbeille)) { + return null; + } + + $lire = static function (string $sql) use ($pdo, $id): array { + $r = $pdo->prepare($sql); + $r->execute([$id]); + return $r->fetchAll(); + }; + + $c['liens'] = $lire('SELECT type, url FROM liens WHERE candidat_id = ? ORDER BY id'); + $c['experiences'] = $lire('SELECT * FROM experiences WHERE candidat_id = ? ORDER BY (date_fin IS NULL) DESC, date_fin DESC, date_debut DESC'); + $c['formations'] = $lire('SELECT * FROM formations WHERE candidat_id = ? ORDER BY COALESCE(date_fin, annee_fin) DESC, id'); + $c['certifications'] = $lire('SELECT * FROM certifications WHERE candidat_id = ? ORDER BY annee DESC'); + $c['langues'] = $lire('SELECT langue, niveau, ecoute, lecture, production_orale, interaction_orale, ecriture FROM langues WHERE candidat_id = ? ORDER BY id'); + $c['technologies'] = $lire( + 'SELECT t.nom, t.categorie, ct.annees FROM candidat_technologie ct + JOIN technologies t ON t.id = ct.technologie_id + WHERE ct.candidat_id = ? ORDER BY ct.annees DESC, t.nom' + ); + $c['documents'] = $lire( + 'SELECT d.id, d.type, d.nom_fichier, d.taille_octets, d.ajoute_le, u.nom_affiche + FROM documents d JOIN utilisateurs u ON u.id = d.ajoute_par + WHERE d.candidat_id = ? ORDER BY d.ajoute_le, d.id' + ); + $c['commentaires'] = $lire( + 'SELECT c.id, c.texte, c.cree_le, u.nom_affiche + FROM commentaires c JOIN utilisateurs u ON u.id = c.auteur_id + WHERE c.candidat_id = ? ORDER BY c.cree_le DESC, c.id DESC' + ); + $c['historique'] = $lire( + 'SELECT h.action, h.champ, h.ancienne_valeur, h.nouvelle_valeur, h.cree_le, u.nom_affiche + FROM historique h JOIN utilisateurs u ON u.id = h.utilisateur_id + WHERE h.candidat_id = ? ORDER BY h.cree_le DESC, h.id DESC' + ); + $c['entretiens'] = $lire( + 'SELECT e.*, u.nom_affiche, b.client AS besoin_client, b.intitule AS besoin_intitule + FROM entretiens e JOIN utilisateurs u ON u.id = e.cree_par LEFT JOIN besoins b ON b.id = e.besoin_id + WHERE e.candidat_id = ? ORDER BY e.date_entretien DESC, e.id DESC' + ); + $c['relances'] = $lire( + 'SELECT r.*, u.nom_affiche, f.nom_affiche AS fait_par_nom + FROM relances r JOIN utilisateurs u ON u.id = r.cree_par LEFT JOIN utilisateurs f ON f.id = r.fait_par + WHERE r.candidat_id = ? ORDER BY (r.fait_le IS NOT NULL), r.echeance, r.id' + ); + $c['besoins'] = $lire( + 'SELECT b.id, b.client, b.intitule, b.statut, bc.etat, bc.note + FROM besoin_candidat bc JOIN besoins b ON b.id = bc.besoin_id + WHERE bc.candidat_id = ? ORDER BY bc.ajoute_le DESC' + ); + $c['points_attention'] = json_decode($c['json_source'], true)['points_attention'] ?? []; + $c['nationalites_codes'] = codes_nationalites($c['nationalites']); + // Dernier CV d'origine chargé : proposé en téléchargement direct + $c['cv_origine'] = null; + foreach ($c['documents'] as $d) { + if ($d['type'] === 'cv_origine') { + $c['cv_origine'] = (int) $d['id']; + } + } + $c['mots_cles'] = array_fill_keys(array_keys(TYPES_MOT), []); + foreach ($lire('SELECT id, type, mot, manuel FROM mots_cles WHERE candidat_id = ? ORDER BY mot') as $m) { + $c['mots_cles'][$m['type']][] = $m; + } + + return $c; +} + +/** + * Change le statut d'un candidat et journalise le changement. + * @throws InvalidArgumentException si la demande est incohérente + */ +function changer_statut(int $id, string $statut, ?string $motif): void +{ + if (!isset(STATUTS[$statut])) { + throw new InvalidArgumentException(t('Statut inconnu.')); + } + $motif = $motif !== null ? trim($motif) : ''; + $pdo = db(); + $req = $pdo->prepare('SELECT statut, archive_le FROM candidats WHERE id = ? AND supprime_le IS NULL'); + $req->execute([$id]); + $ligne = $req->fetch(); + if (!$ligne) { + throw new InvalidArgumentException(t('Candidat introuvable.')); + } + if ($ligne['archive_le'] !== null) { + throw new InvalidArgumentException(t('Ce profil est archivé. Désarchivez-le pour changer son statut.')); + } + $ancien = $ligne['statut']; + if ($ancien === $statut) { + return; + } + if ($statut === 'non_retenu' && $motif === '') { + throw new InvalidArgumentException(t('Indiquez le motif du refus.')); + } + + $req = $pdo->prepare('UPDATE candidats SET statut = ?, motif_refus = ?, modifie_par = ?, modifie_le = NOW() WHERE id = ?'); + $req->execute([$statut, $statut === 'non_retenu' ? mb_substr($motif, 0, 500) : null, utilisateur()['id'], $id]); + + // Le journal garde les codes des statuts : l'affichage les traduit. Le motif suit le code, après une barre. + journaliser($id, 'changement_statut', 'statut', $ancien, $statut . ($statut === 'non_retenu' ? '|' . mb_substr($motif, 0, 500) : '')); +} + +/** Enregistre l'ordre des cartes d'une colonne. */ +function ordonner_colonne(string $statut, array $ids): void +{ + $req = db()->prepare('UPDATE candidats SET position_kanban = ? WHERE id = ? AND statut = ?'); + foreach (array_values($ids) as $position => $id) { + $req->execute([$position + 1, (int) $id, $statut]); + } +} + +/** + * Range un fichier dans le stockage et l'attache au candidat. + * @param array $f caractéristiques renvoyées par controler_fichier(), ou fichier déjà déplacé (clé "chemin_tmp") + */ +function attacher_document(int $candidatId, string $type, array $f): void +{ + $relatif = 'docs/' . bin2hex(random_bytes(16)) . '.' . $f['ext']; + $cible = dossier_stockage('docs') . '/' . basename($relatif); + + $ok = isset($f['chemin_tmp']) ? rename($f['chemin_tmp'], $cible) : move_uploaded_file($f['tmp'], $cible); + if (!$ok) { + throw new RuntimeException(t('Le fichier n\'a pas pu être enregistré.')); + } + chmod($cible, 0600); + + $req = db()->prepare( + 'INSERT INTO documents (candidat_id, type, nom_fichier, chemin, type_mime, taille_octets, ajoute_par) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + $req->execute([$candidatId, $type, mb_substr($f['nom'], 0, 255), $relatif, $f['mime'], $f['taille'], utilisateur()['id']]); + journaliser($candidatId, 'ajout_document', $type, null, $f['nom']); +} + +/** Document d'un profil. Ceux d'un profil à la corbeille sont introuvables. */ +function document(int $id): ?array +{ + $req = db()->prepare('SELECT d.* FROM documents d JOIN candidats c ON c.id = d.candidat_id AND c.supprime_le IS NULL WHERE d.id = ?'); + $req->execute([$id]); + return $req->fetch() ?: null; +} + +/** Comptes acceptés, actifs ou désactivés. Les demandes en attente sont à part : demandes_compte(). */ +function utilisateurs(): array +{ + return db()->query('SELECT id, identifiant, nom_affiche, role, actif, cree_le, derniere_connexion, totp_active_le, changer_mot_de_passe, codes_secours + FROM utilisateurs WHERE en_attente = 0 ORDER BY nom_affiche')->fetchAll(); +} + +/** Demandes de compte à valider, les plus anciennes d'abord. */ +function demandes_compte(): array +{ + return db()->query('SELECT id, identifiant, nom_affiche, cree_le FROM utilisateurs WHERE en_attente = 1 ORDER BY cree_le, id')->fetchAll(); +} + +function nombre_demandes(): int +{ + return (int) db()->query('SELECT COUNT(*) FROM utilisateurs WHERE en_attente = 1')->fetchColumn(); +} + +/** Accepte une demande : le compte devient actif. Renvoie le compte, ou null si la demande n'existe plus. */ +function accepter_demande(int $id): ?array +{ + $d = demande_compte($id); + if ($d === null) { + return null; + } + db()->prepare('UPDATE utilisateurs SET actif = 1, en_attente = 0 WHERE id = ? AND en_attente = 1')->execute([$id]); + journaliser(null, 'compte', 'acceptation', null, $d['identifiant']); + return $d; +} + +/** Refuse une demande : elle est effacée, l'identifiant redevient libre. */ +function refuser_demande(int $id): ?array +{ + $d = demande_compte($id); + if ($d === null) { + return null; + } + db()->prepare('DELETE FROM utilisateurs WHERE id = ? AND en_attente = 1')->execute([$id]); + journaliser(null, 'compte', 'refus', null, $d['identifiant']); + return $d; +} + +function demande_compte(int $id): ?array +{ + $req = db()->prepare('SELECT id, identifiant, nom_affiche, cree_le FROM utilisateurs WHERE id = ? AND en_attente = 1'); + $req->execute([$id]); + return $req->fetch() ?: null; +} + +/** + * Met en forme et contrôle un identifiant de connexion. + * @throws InvalidArgumentException + */ +function nettoyer_identifiant(string $identifiant, int $saufId = 0): string +{ + $identifiant = mb_strtolower(trim($identifiant)); + if (!preg_match('/^[a-z0-9][a-z0-9._\-]{2,39}$/', $identifiant)) { + throw new InvalidArgumentException(t('L\'identifiant compte 3 à 40 caractères : lettres sans accent, chiffres, point, tiret.')); + } + $req = db()->prepare('SELECT 1 FROM utilisateurs WHERE identifiant = ? AND id <> ?'); + $req->execute([$identifiant, $saufId]); + if ($req->fetchColumn()) { + throw new InvalidArgumentException(t('Cet identifiant est déjà pris.')); + } + return $identifiant; +} + +/** + * @param bool $aChanger mot de passe donné par le gestionnaire : la personne le change à sa première connexion + * @throws InvalidArgumentException + */ +function creer_utilisateur(string $identifiant, string $nom, string $motDePasse, string $role = 'recruteur', bool $aChanger = false): int +{ + $identifiant = nettoyer_identifiant($identifiant); + $nom = trim($nom); + if ($nom === '') { + throw new InvalidArgumentException(t('Indiquez le nom de la personne.')); + } + if (!isset(ROLES[$role])) { + throw new InvalidArgumentException(t('Rôle inconnu.')); + } + controler_mot_de_passe($motDePasse); + $req = db()->prepare('INSERT INTO utilisateurs (identifiant, nom_affiche, mot_de_passe, role, actif, en_attente, changer_mot_de_passe) VALUES (?, ?, ?, ?, 1, 0, ?)'); + $req->execute([$identifiant, mb_substr($nom, 0, 120), hacher_mot_de_passe($motDePasse), $role, $aChanger ? 1 : 0]); + return (int) db()->lastInsertId(); +} + +/** @throws InvalidArgumentException */ +function modifier_utilisateur(int $id, string $identifiant, string $nom): void +{ + $identifiant = nettoyer_identifiant($identifiant, $id); + $nom = trim($nom); + if ($nom === '') { + throw new InvalidArgumentException(t('Indiquez le nom de la personne.')); + } + db()->prepare('UPDATE utilisateurs SET identifiant = ?, nom_affiche = ? WHERE id = ?')->execute([$identifiant, mb_substr($nom, 0, 120), $id]); +} + +/** Référence d'un profil, utilisée à la place du nom sur le CV anonymisé. */ +function reference_profil(array $c): string +{ + return sprintf('ITC-%04d', $c['id']); +} + +/** Remplace le nom et le prénom du candidat par sa référence dans un texte libre. */ +function anonymiser_texte(?string $texte, array $c): string +{ + if ($texte === null || $texte === '') { + return ''; + } + $ref = reference_profil($c); + $motifs = []; + foreach ([$c['prenom'] . ' ' . $c['nom'], $c['nom'] . ' ' . $c['prenom'], $c['nom'], $c['prenom']] as $n) { + if (mb_strlen(trim($n)) >= 2) { + $motifs[] = '/(? $args ? vsprintf(traduire($cle, $langue), $args) : traduire($cle, $langue); + $txt = static fn (?string $t): string => $anonyme ? anonymiser_texte($t, $c) : (string) $t; + $mois = static fn (?string $d): string => $d ? (new DateTimeImmutable($d))->format('m/Y') : $L('en cours'); + $lignes = static fn (string $t): array => array_values(array_filter(array_map('trim', preg_split('/\R/u', $t) ?: []), 'strlen')); + $tr = $traduction ?? []; + $nomComplet = $c['prenom'] . ' ' . $c['nom']; + + $reperes = array_filter([ + $L('Séniorité') => $L(SENIORITES[$c['seniorite']]), + $L('Expérience') => annees($c['annees_experience'], $langue), + $L('Langues') => implode(' · ', array_map(static fn ($l) => $L($l['langue']) . ($l['niveau'] ? ' ' . $L($l['niveau']) : ''), $c['langues'])), + $L('Disponibilité') => libelle_disponibilite($c, $langue, false) ?: $txt($tr['disponibilite'] ?? $c['disponibilite']), + $L('Localisation') => $anonyme ? '' : implode(', ', array_filter([$c['ville'], $c['pays']])), + ], static fn ($v) => $v !== ''); + + $libellesCategorie = [ + 'langage' => 'Langages', 'framework' => 'Frameworks', 'base_de_donnees' => 'Bases de données', 'outil' => 'Outils', + 'cloud' => 'Cloud', 'methode' => 'Méthodes', 'autre' => 'Autres', + ]; + $competences = []; + foreach ($libellesCategorie as $code => $libelle) { + foreach ($c['technologies'] as $t) { + if ($t['categorie'] === $code) { + $competences[$L($libelle)][] = ['nom' => $t['nom'], 'annees' => $t['annees'] === null ? '' : annees($t['annees'], $langue)]; + } + } + } + + $experiences = []; + foreach ($c['experiences'] as $x) { + $t = $tr['experiences'][$x['id']] ?? []; + $experiences[] = [ + 'debut' => $mois($x['date_debut']), + 'fin' => $mois($x['date_fin']), + 'poste' => $txt($t['poste'] ?? $x['poste']), + 'contexte' => implode(' · ', array_filter([$x['employeur'], $x['client'] ? $L('Client : %s', $x['client']) : null, $x['lieu']])), + 'cadre' => implode(' · ', array_filter([ + $x['projet'] ? $L('Projet : %s', $txt($x['projet'])) : null, + ($t['charge'] ?? $x['charge']) ? $L('Charge : %s', $txt($t['charge'] ?? $x['charge'])) : null, + ])), + 'mission' => $txt($t['mission'] ?? $x['mission']), + 'lignes' => $lignes($txt($t['description'] ?? $x['description'])), + 'environnement' => (string) $x['technologies'], + ]; + } + + $ligne = static fn (string $titre, ?string $lieu, $annee): array => [ + 'titre' => $titre, + 'detail' => implode(' · ', array_filter([$lieu, $annee ? (string) (int) $annee : null])), + ]; + + return [ + 'anonyme' => $anonyme, + 'langue' => $langue, + 'nom' => $anonyme ? $L('Profil %s', $ref) : $nomComplet, + 'titre' => $txt($tr['titre'] ?? $c['titre']), + 'reperes' => $reperes, + 'resume' => $lignes($txt($tr['resume'] ?? $c['resume'])), + 'competences' => $competences, + 'experiences' => $experiences, + 'formations' => array_map(static fn ($f) => $ligne($tr['formations'][$f['id']] ?? $f['diplome'], $f['etablissement'], $f['annee_fin']), $c['formations']), + 'certifications' => array_map(static fn ($f) => $ligne($tr['certifications'][$f['id']] ?? $f['nom'], $f['organisme'], $f['annee']), $c['certifications']), + 'libelles' => [ + 'synthese' => $L('Synthèse'), 'competences' => $L('Compétences techniques'), 'experiences' => $L('Expériences professionnelles'), + 'formation' => $L('Formation'), 'certifications' => $L('Certifications'), 'environnement' => $L('Environnement :'), + ], + 'signature' => (string) ($societe['signature'] ?? ''), + 'contact' => $L('Pour rencontrer ce consultant : %s', implode(' · ', array_filter([$societe['email'] ?? null, $societe['telephone'] ?? null]))), + 'mentions' => implode(' | ', array_filter([ + $societe['nom'] ?? null, $societe['adresse'] ?? null, + !empty($societe['tva']) ? $L('TVA %s', $societe['tva']) : null, + !empty($societe['rcs']) ? 'RCS ' . $societe['rcs'] : null, + $societe['site'] ?? null, + ])), + 'fichier' => 'CV IT Club - ' . ($anonyme ? $ref : $nomComplet) . ($langue !== 'fr' ? ' (' . strtoupper($langue) . ')' : ''), + ]; +} + +// Traduction du contenu d'un profil : voir src/traduction.php + +// --------------------------------------------------------------------------- +// Modification, suppression, conservation +// --------------------------------------------------------------------------- + +/** + * Champs modifiables à la main sur une fiche : colonne => [libellé, longueur maximale, type]. + * Types : texte, date (AAAA-MM-JJ), entier (euros), teletravail (code de TELETRAVAIL), + * disponibilite (code de DISPONIBILITES), nationalites (codes ISO séparés par des virgules). + */ +const CHAMPS_MODIFIABLES = [ + 'prenom' => ['Prénom', 100, 'texte'], 'nom' => ['Nom', 100, 'texte'], 'titre' => ['Titre', 200, 'texte'], + 'email' => ['Email', 190, 'texte'], 'telephone' => ['Téléphone', 40, 'texte'], + 'rue' => ['Rue', 200, 'texte'], 'code_postal' => ['Code postal', 20, 'texte'], 'ville' => ['Ville', 100, 'texte'], 'pays' => ['Pays', 100, 'texte'], + 'nationalites' => ['Nationalité', 60, 'nationalites'], + 'permis_travail' => ['Permis de travail', 300, 'texte'], 'permis_expire_le' => ['Titre valable jusqu\'au', 10, 'date'], + 'disponibilite_code' => ['Disponibilité', 20, 'disponibilite'], 'disponible_le' => ['Disponible le', 10, 'date'], + 'disponibilite' => ['Note sur la disponibilité', 200, 'texte'], + 'tjm' => ['TJM souhaité', 5, 'entier'], 'salaire_souhaite' => ['Salaire souhaité', 7, 'entier'], + 'mobilite' => ['Mobilité', 200, 'texte'], 'teletravail' => ['Télétravail', 10, 'teletravail'], + 'resume' => ['Synthèse', 5000, 'texte'], + 'benevolat' => ['Bénévolat', 2000, 'texte'], 'permis_conduire' => ['Permis de conduire', 100, 'texte'], +]; + +/** Champs commerciaux : jamais repris sur un CV exporté. */ +const CHAMPS_COMMERCIAUX = ['disponible_le', 'tjm', 'salaire_souhaite', 'mobilite', 'teletravail']; + +/** Lit une date saisie (AAAA-MM-JJ). @throws InvalidArgumentException */ +function date_saisie(string $v, string $libelle): string +{ + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $v); + if (!$d || $d->format('Y-m-d') !== $v) { + throw new InvalidArgumentException(t('%s : date invalide.', t($libelle))); + } + return $v; +} + +/** + * Date de disponibilité qui correspond à un code : « Sous N mois » donne aujourd'hui + N mois, + * « Immédiate » et « À préciser » n'ont pas de date, « Date précise » garde la date saisie. + */ +function date_disponibilite(?string $code, ?string $date, ?DateTimeImmutable $aujourdhui = null): ?string +{ + $aujourdhui ??= new DateTimeImmutable('today'); + return match ($code) { + '1_mois' => $aujourdhui->modify('+1 month')->format('Y-m-d'), + '2_mois' => $aujourdhui->modify('+2 months')->format('Y-m-d'), + '3_mois' => $aujourdhui->modify('+3 months')->format('Y-m-d'), + 'date' => $date, + default => null, + }; +} + +/** + * Classe une disponibilité écrite en texte libre (CV, import au format 1.1). + * Mêmes règles que la migration 1.8 : une date reconnue, « immédiat », « 1, 2 ou 3 mois » ; sinon « À préciser ». + * @return array{code: ?string, date: ?string} + */ +function classer_disponibilite(?string $texte, ?DateTimeImmutable $aujourdhui = null): array +{ + $t = mb_strtolower(trim((string) $texte)); + if ($t === '') { + return ['code' => null, 'date' => null]; + } + if (preg_match('#\b(\d{2})/(\d{2})/(\d{4})\b#', $t, $m) && checkdate((int) $m[2], (int) $m[1], (int) $m[3])) { + return ['code' => 'date', 'date' => sprintf('%04d-%02d-%02d', $m[3], $m[2], $m[1])]; + } + if (preg_match('#\b(\d{4})-(\d{2})-(\d{2})\b#', $t, $m) && checkdate((int) $m[2], (int) $m[3], (int) $m[1])) { + return ['code' => 'date', 'date' => "$m[1]-$m[2]-$m[3]"]; + } + if (preg_match('/immédiat|immediat|tout de suite|sans préavis|available now|immediately|right away|sofort|^disponible\.?$/u', $t)) { + return ['code' => 'immediate', 'date' => null]; + } + foreach ([ + '1_mois' => '/(^|[^0-9])(1|un|one) (mois|month)|4 semaines|4 weeks|30 jours|30 days|1 monat/u', + '2_mois' => '/(^|[^0-9])(2|deux|two) (mois|months)|8 semaines|8 weeks|60 jours|60 days|2 monate/u', + '3_mois' => '/(^|[^0-9])(3|trois|three) (mois|months)|12 semaines|12 weeks|90 jours|90 days|3 monate/u', + ] as $code => $motif) { + if (preg_match($motif, $t)) { + return ['code' => $code, 'date' => date_disponibilite($code, null, $aujourdhui)]; + } + } + return ['code' => 'a_preciser', 'date' => null]; +} + +/** + * Disponibilité à afficher : « Immédiate », « Sous 1 mois », « À partir du 01/12/2026 », « À préciser ». + * @param bool $estimation ajoute la date estimée de « Sous N mois » (fiche, listes ; pas sur un CV) + */ +function libelle_disponibilite(array $c, ?string $langue = null, bool $estimation = true): string +{ + $langue ??= langue_courante(); + $code = $c['disponibilite_code'] ?? null; + if ($code === null || !isset(DISPONIBILITES[$code])) { + return ''; + } + if ($code === 'date') { + return $c['disponible_le'] ? sprintf(traduire('À partir du %s', $langue), date_fr($c['disponible_le'])) : traduire('Date précise', $langue); + } + $texte = traduire(DISPONIBILITES[$code], $langue); + if ($estimation && in_array($code, ['1_mois', '2_mois', '3_mois'], true) && $c['disponible_le']) { + $texte .= ' · ' . sprintf(traduire('vers le %s', $langue), date_fr($c['disponible_le'])); + } + return $texte; +} + +/** + * Enregistre les champs modifiés d'une fiche et journalise chaque changement. + * @return int nombre de champs modifiés + * @throws InvalidArgumentException + */ +function modifier_candidat(array $c, array $saisie): int +{ + $nouveau = []; + $afficher = []; // valeur écrite dans le journal, quand elle diffère de la valeur enregistrée + foreach (CHAMPS_MODIFIABLES as $champ => [$libelle, $max, $type]) { + $v = $type === 'nationalites' && is_array($saisie[$champ] ?? null) + ? implode(',', $saisie[$champ]) + : trim((string) ($saisie[$champ] ?? '')); + if ($v === '') { + $nouveau[$champ] = null; + } elseif ($type === 'date') { + $nouveau[$champ] = date_saisie($v, $libelle); + $afficher[$champ] = date_fr($v); + } elseif ($type === 'entier') { + if (!ctype_digit($v) || strlen($v) > $max) { + throw new InvalidArgumentException(t('%s : nombre entier attendu, sans espace ni symbole.', t($libelle))); + } + $nouveau[$champ] = (int) $v; + } elseif ($type === 'teletravail') { + if (!isset(TELETRAVAIL[$v])) { + throw new InvalidArgumentException(t('%s : valeur inconnue.', t($libelle))); + } + $nouveau[$champ] = $v; + $afficher[$champ] = TELETRAVAIL[$v]; + } elseif ($type === 'disponibilite') { + if (!isset(DISPONIBILITES[$v])) { + throw new InvalidArgumentException(t('%s : valeur inconnue.', t($libelle))); + } + $nouveau[$champ] = $v; + $afficher[$champ] = DISPONIBILITES[$v]; + } elseif ($type === 'nationalites') { + $codes = codes_nationalites($v); + $nouveau[$champ] = $codes ? implode(',', $codes) : null; + $afficher[$champ] = implode(', ', array_map(static fn ($p) => nom_pays($p, 'fr'), $codes)); + } else { + $nouveau[$champ] = mb_substr($v, 0, $max); + } + } + if ($nouveau['prenom'] === null || $nouveau['nom'] === null) { + throw new InvalidArgumentException(t('Le prénom et le nom sont obligatoires.')); + } + $nouveau['nom'] = mb_strtoupper($nouveau['nom']); // nom de famille en capitales, partout dans Vivier + if ($nouveau['email'] !== null) { + $nouveau['email'] = mb_strtolower($nouveau['email']); + if (!filter_var($nouveau['email'], FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException(t('Adresse email invalide.')); + } + $req = db()->prepare('SELECT id FROM candidats WHERE email = ? AND id <> ?'); + $req->execute([$nouveau['email'], $c['id']]); + if ($req->fetchColumn()) { + throw new InvalidArgumentException(t('Un autre profil utilise déjà cette adresse email.')); + } + } + + // Disponibilité : la date suit le code. « Date précise » demande une date ; « Sous N mois » reçoit une date estimée, + // recalculée seulement si le choix change, pour ne pas glisser à chaque enregistrement. + $code = $nouveau['disponibilite_code']; + if ($code === 'date' && $nouveau['disponible_le'] === null) { + throw new InvalidArgumentException(t('Disponibilité : indiquez la date.')); + } + if (in_array($code, ['1_mois', '2_mois', '3_mois'], true)) { + $nouveau['disponible_le'] = $code === $c['disponibilite_code'] && $c['disponible_le'] ? $c['disponible_le'] : date_disponibilite($code, null); + } elseif ($code !== 'date') { + $nouveau['disponible_le'] = null; + } + if (isset($nouveau['disponible_le']) && $nouveau['disponible_le'] !== null) { + $afficher['disponible_le'] = date_fr($nouveau['disponible_le']); + } + + // Nationalités : elles décident si un permis de travail est nécessaire. Une nationalité UE, EEE ou suisse suffit. + $horsUe = hors_ue(codes_nationalites($nouveau['nationalites'])); + + // La base rend les nombres en entiers et les dates en texte : la comparaison se fait type pour type + $changes = array_filter($nouveau, static fn ($v, $k) => $v !== (is_int($v) && $c[$k] !== null ? (int) $c[$k] : $c[$k]), ARRAY_FILTER_USE_BOTH); + if (!$changes) { + return 0; + } + $pdo = db(); + $pdo->beginTransaction(); + $pdo->prepare('UPDATE candidats SET ' . implode(' = ?, ', array_keys($changes)) . ' = ?, hors_ue = ?, modifie_par = ?, modifie_le = NOW() WHERE id = ?') + ->execute([...array_values($changes), $horsUe, utilisateur()['id'], $c['id']]); + foreach ($changes as $champ => $v) { + $type = CHAMPS_MODIFIABLES[$champ][2]; + $ancien = $c[$champ] === null ? null : match ($type) { + 'date' => date_fr((string) $c[$champ]), + 'teletravail' => TELETRAVAIL[$c[$champ]] ?? (string) $c[$champ], + 'disponibilite' => DISPONIBILITES[$c[$champ]] ?? (string) $c[$champ], + 'nationalites' => implode(', ', array_map(static fn ($p) => nom_pays($p, 'fr'), codes_nationalites($c[$champ]))), + default => (string) $c[$champ], + }; + journaliser((int) $c['id'], 'modification', CHAMPS_MODIFIABLES[$champ][0], $ancien, $v === null ? null : ($afficher[$champ] ?? (string) $v)); + } + if (array_key_exists('permis_expire_le', $changes) || array_key_exists('nationalites', $changes)) { + planifier_relance_permis((int) $c['id'], $horsUe === 0 ? null : $nouveau['permis_expire_le']); + } + Recherche::indexer($pdo, (int) $c['id']); + $pdo->commit(); + if (array_intersect(array_keys($changes), ['titre', 'resume', 'disponibilite', 'benevolat'])) { + planifier_traduction((int) $c['id']); + } + return count($changes); +} + +/** + * Relance « renouvellement du titre » : 2 mois avant l'expiration du titre de séjour, ou tout de suite si la date est plus proche. + * Une seule relance automatique par profil : elle suit la date du titre. Sans date, elle disparaît si elle n'est pas faite. + */ +function planifier_relance_permis(int $candidatId, ?string $expireLe): void +{ + $pdo = db(); + $pdo->prepare("DELETE FROM relances WHERE candidat_id = ? AND auto = 'permis' AND fait_le IS NULL")->execute([$candidatId]); + if ($expireLe === null) { + return; + } + $fin = new DateTimeImmutable($expireLe); + $echeance = max(new DateTimeImmutable('today'), $fin->modify('-2 months')); + $objet = 'Renouvellement du titre de séjour : il expire le ' . $fin->format('d/m/Y'); + $pdo->prepare("INSERT INTO relances (candidat_id, echeance, objet, auto, cree_par) VALUES (?, ?, ?, 'permis', ?)") + ->execute([$candidatId, $echeance->format('Y-m-d'), $objet, utilisateur()['id']]); + journaliser($candidatId, 'relance', 'ajout', null, $echeance->format('Y-m-d') . '|' . $objet); +} + +// --------------------------------------------------------------------------- +// Corbeille : suppression en deux temps +// --------------------------------------------------------------------------- + +/** + * Met un profil à la corbeille. Il disparaît de toute l'application et reste récupérable + * pendant CORBEILLE_JOURS jours. Le journal ne note que la référence. + */ +function mettre_a_la_corbeille(array $c): void +{ + if ($c['supprime_le'] !== null) { + return; + } + db()->prepare('UPDATE candidats SET supprime_le = NOW(), supprime_par = ? WHERE id = ?')->execute([utilisateur()['id'], $c['id']]); + journaliser((int) $c['id'], 'corbeille', null, null, reference_profil($c)); +} + +/** Sort un profil de la corbeille. Il retrouve son statut ; s'il n'est pas archivé, il revient en bas de sa colonne. */ +function restaurer_candidat(array $c): void +{ + if ($c['supprime_le'] === null) { + return; + } + $req = db()->prepare('SELECT COALESCE(MAX(position_kanban), 0) + 1 FROM candidats WHERE statut = ? AND archive_le IS NULL AND supprime_le IS NULL'); + $req->execute([$c['statut']]); + db()->prepare('UPDATE candidats SET supprime_le = NULL, supprime_par = NULL, position_kanban = ? WHERE id = ?') + ->execute([(int) $req->fetchColumn(), $c['id']]); + journaliser((int) $c['id'], 'restauration', null, null, reference_profil($c)); +} + +/** Profils à la corbeille, les plus proches de l'effacement d'abord. */ +function corbeille(): array +{ + return db()->query( + 'SELECT c.id, c.prenom, c.nom, c.titre, c.statut, c.photo, c.archive_le, c.supprime_le, u.nom_affiche AS supprime_par_nom, + (SELECT COUNT(*) FROM documents d WHERE d.candidat_id = c.id) AS documents, + GREATEST(0, ' . CORBEILLE_JOURS . ' - DATEDIFF(CURDATE(), DATE(c.supprime_le))) AS jours_restants + FROM candidats c LEFT JOIN utilisateurs u ON u.id = c.supprime_par + WHERE c.supprime_le IS NOT NULL ORDER BY c.supprime_le, c.id' + )->fetchAll(); +} + +function nombre_corbeille(): int +{ + return (int) db()->query('SELECT COUNT(*) FROM candidats WHERE supprime_le IS NOT NULL')->fetchColumn(); +} + +/** + * Efface pour de bon les profils à la corbeille depuis plus de CORBEILLE_JOURS jours. + * Appelée par la tâche planifiée et à la connexion du gestionnaire. + * @return int nombre de profils effacés + */ +function purger_corbeille(): int +{ + $pdo = db(); + $ids = $pdo->query('SELECT id FROM candidats WHERE supprime_le IS NOT NULL AND supprime_le < DATE_SUB(NOW(), INTERVAL ' . CORBEILLE_JOURS . ' DAY)') + ->fetchAll(PDO::FETCH_COLUMN); + $n = 0; + foreach ($ids as $id) { + $req = $pdo->prepare('SELECT id, photo, supprime_le, supprime_par FROM candidats WHERE id = ?'); + $req->execute([$id]); + $c = $req->fetch(); + if ($c && $c['supprime_le'] !== null) { + supprimer_candidat($c); + $n++; + } + } + return $n; +} + +/** + * Efface un profil, ses données et ses fichiers. Sans retour possible. + * La trace du journal ne garde que la référence, la date de mise à la corbeille et le compte qui a supprimé. + * @param array $c doit contenir id, photo, supprime_le, supprime_par + */ +function supprimer_candidat(array $c): void +{ + $pdo = db(); + $req = $pdo->prepare('SELECT chemin FROM documents WHERE candidat_id = ?'); + $req->execute([$c['id']]); + $chemins = $req->fetchAll(PDO::FETCH_COLUMN); + // L'auteur de la ligne : le compte connecté, sinon celui qui a mis le profil à la corbeille (tâche planifiée) + $auteur = utilisateur()['id'] ?? ($c['supprime_par'] !== null ? (int) $c['supprime_par'] : null); + if ($auteur === null) { + throw new RuntimeException(t('Suppression impossible : aucun compte à qui l\'attribuer.')); + } + + $pdo->beginTransaction(); + // Les anciennes lignes du journal contiennent des noms et des valeurs : elles partent avec le profil + $pdo->prepare('DELETE FROM historique WHERE candidat_id = ?')->execute([$c['id']]); + $pdo->prepare('DELETE FROM candidats WHERE id = ?')->execute([$c['id']]); + journaliser((int) $c['id'], 'suppression', $c['supprime_le'] ?? null, null, 'Profil ' . reference_profil($c) . ' supprimé', (int) $auteur); + $pdo->commit(); + + foreach ($chemins as $chemin) { + @unlink(dossier_stockage() . '/' . $chemin); + } + if (!empty($c['photo'])) { + @unlink(dossier_stockage() . '/' . $c['photo']); + } +} + +function supprimer_document(array $d): void +{ + db()->prepare('DELETE FROM documents WHERE id = ?')->execute([$d['id']]); + @unlink(dossier_stockage() . '/' . $d['chemin']); + journaliser((int) $d['candidat_id'], 'suppression_document', $d['type'], $d['nom_fichier'], null); +} + +/** Objet d'une relance à afficher. Celle que Vivier crée pour le titre de séjour suit la langue de l'interface. */ +function objet_relance(array $r): string +{ + if (($r['auto'] ?? null) === 'permis' && preg_match('#(\d{2}/\d{2}/\d{4})#', (string) $r['objet'], $m)) { + return t('Renouvellement du titre de séjour : il expire le %s', $m[1]); + } + return (string) $r['objet']; +} + +/** Profils dont la conservation est échue ou le sera dans le délai indiqué. */ +function candidats_a_echeance(int $joursAvant = 60): array +{ + $req = db()->prepare( + 'SELECT id, prenom, nom, titre, statut, conserver_jusqu_au, cree_le, consentement_le, archive_le, DATEDIFF(conserver_jusqu_au, CURDATE()) AS jours + FROM candidats WHERE supprime_le IS NULL AND conserver_jusqu_au IS NOT NULL AND conserver_jusqu_au <= DATE_ADD(CURDATE(), INTERVAL ? DAY) + ORDER BY conserver_jusqu_au, nom' + ); + $req->execute([$joursAvant]); + return $req->fetchAll(); +} + +function nombre_echus(): int +{ + return (int) db()->query('SELECT COUNT(*) FROM candidats WHERE conserver_jusqu_au < CURDATE() AND supprime_le IS NULL')->fetchColumn(); +} + +/** Repousse l'échéance de conservation, à compter d'aujourd'hui. */ +function prolonger_conservation(array $c, int $mois): void +{ + $date = (new DateTimeImmutable('today'))->modify("+$mois months")->format('Y-m-d'); + db()->prepare('UPDATE candidats SET conserver_jusqu_au = ? WHERE id = ?')->execute([$date, $c['id']]); + journaliser((int) $c['id'], 'modification', 'Conservation', date_fr($c['conserver_jusqu_au']), date_fr($date)); +} + +/** Dernières lignes du journal, tous profils confondus. */ +function journal(?int $utilisateurId = null, int $limite = 300): array +{ + $sql = 'SELECT h.*, u.nom_affiche, c.prenom, c.nom, c.supprime_le + FROM historique h JOIN utilisateurs u ON u.id = h.utilisateur_id + LEFT JOIN candidats c ON c.id = h.candidat_id' + . ($utilisateurId ? ' WHERE h.utilisateur_id = ?' : '') + . ' ORDER BY h.cree_le DESC, h.id DESC LIMIT ' . $limite; + $req = db()->prepare($sql); + $req->execute($utilisateurId ? [$utilisateurId] : []); + return $req->fetchAll(); +} + +// --------------------------------------------------------------------------- +// Mots de passe +// --------------------------------------------------------------------------- + +/** + * Change le mot de passe d'un compte et ferme ses autres sessions. + * @param bool $aChanger vrai quand le gestionnaire le donne : la personne devra le changer à sa prochaine connexion + * @throws InvalidArgumentException + */ +function changer_mot_de_passe(int $utilisateurId, string $nouveau, bool $aChanger = false): void +{ + controler_mot_de_passe($nouveau); + db()->prepare('UPDATE utilisateurs SET mot_de_passe = ?, changer_mot_de_passe = ?, session_version = session_version + 1 WHERE id = ?') + ->execute([hacher_mot_de_passe($nouveau), $aChanger ? 1 : 0, $utilisateurId]); +} + + +// --------------------------------------------------------------------------- +// Export CSV et tableau de bord +// --------------------------------------------------------------------------- + +/** Identifiant du dernier CV d'origine de chaque profil : [candidat_id => document_id]. */ +function cv_origine_par_candidat(array $ids): array +{ + if (!$ids) { + return []; + } + $liste = implode(', ', array_map('intval', $ids)); + return db()->query("SELECT candidat_id, MAX(id) FROM documents WHERE type = 'cv_origine' AND candidat_id IN ($liste) GROUP BY candidat_id") + ->fetchAll(PDO::FETCH_KEY_PAIR); +} + +/** + * Lignes de l'export CSV pour une liste de profils, dans l'ordre fourni. + * @return array> + */ +function lignes_export(array $ids): array +{ + if (!$ids) { + return []; + } + $pdo = db(); + $liste = implode(', ', array_map('intval', $ids)); + $grouper = static fn (string $sql): array => $pdo->query($sql)->fetchAll(PDO::FETCH_KEY_PAIR); + + $langues = $grouper("SELECT candidat_id, GROUP_CONCAT(CONCAT(langue, IF(niveau IS NULL, '', CONCAT(' ', niveau))) ORDER BY id SEPARATOR ', ') FROM langues WHERE candidat_id IN ($liste) GROUP BY candidat_id"); + $technos = $grouper("SELECT ct.candidat_id, GROUP_CONCAT(t.nom ORDER BY ct.annees DESC, t.nom SEPARATOR ', ') FROM candidat_technologie ct JOIN technologies t ON t.id = ct.technologie_id WHERE ct.candidat_id IN ($liste) GROUP BY ct.candidat_id"); + $metiers = $grouper("SELECT candidat_id, GROUP_CONCAT(mot ORDER BY mot SEPARATOR ', ') FROM mots_cles WHERE type = 'role' AND candidat_id IN ($liste) GROUP BY candidat_id"); + $secteurs = $grouper("SELECT candidat_id, GROUP_CONCAT(mot ORDER BY mot SEPARATOR ', ') FROM mots_cles WHERE type = 'secteur' AND candidat_id IN ($liste) GROUP BY candidat_id"); + + $parId = []; + foreach ($pdo->query("SELECT * FROM candidats WHERE id IN ($liste)") as $c) { + $parId[$c['id']] = $c; + } + $lignes = []; + foreach ($ids as $id) { + $c = $parId[$id] ?? null; + if ($c === null) { + continue; + } + $lignes[] = [ + 'Référence' => reference_profil($c), + 'Prénom' => $c['prenom'], + 'Nom' => $c['nom'], + 'Titre' => (string) $c['titre'], + 'Séniorité' => t(SENIORITES[$c['seniorite']]), + 'Années d\'expérience' => nombre($c['annees_experience']), + 'Statut' => t(STATUTS[$c['statut']]), + 'Motif du refus' => (string) $c['motif_refus'], + 'Email' => (string) $c['email'], + 'Téléphone' => (string) $c['telephone'], + 'Ville' => (string) $c['ville'], + 'Pays' => (string) $c['pays'], + 'Nationalité' => implode(', ', array_map(static fn ($p) => nom_pays($p), codes_nationalites($c['nationalites']))), + 'Permis de travail' => $c['hors_ue'] !== null && (int) $c['hors_ue'] === 0 ? t('Non requis') : (string) $c['permis_travail'], + 'Titre valable jusqu\'au' => date_fr($c['permis_expire_le']), + 'Disponibilité' => libelle_disponibilite($c, null, false), + 'Disponible le' => date_fr($c['disponible_le']), + 'Note sur la disponibilité' => (string) $c['disponibilite'], + 'TJM souhaité' => (string) $c['tjm'], + 'Salaire souhaité' => (string) $c['salaire_souhaite'], + 'Mobilité' => (string) $c['mobilite'], + 'Télétravail' => $c['teletravail'] ? t(TELETRAVAIL[$c['teletravail']]) : '', + 'Niveau d\'études' => (string) (NIVEAUX_ETUDES[$c['niveau_etudes']] ?? ''), + 'Langues' => (string) ($langues[$id] ?? ''), + 'Technologies' => (string) ($technos[$id] ?? ''), + 'Métiers' => (string) ($metiers[$id] ?? ''), + 'Secteurs' => (string) ($secteurs[$id] ?? ''), + 'Reçu le' => date_fr($c['cree_le']), + 'Conservation jusqu\'au' => date_fr($c['conserver_jusqu_au']), + 'Consentement le' => date_fr($c['consentement_le']), + 'Archivé le' => date_fr($c['archive_le']), + ]; + } + return $lignes; +} + +/** Chiffres du tableau de bord. */ +function statistiques(): array +{ + $pdo = db(); + $compter = static fn (string $sql): array => $pdo->query($sql)->fetchAll(PDO::FETCH_KEY_PAIR); + + // Les profils archivés sortent des chiffres : ils sont comptés à part + $parStatut = array_replace(array_fill_keys(array_keys(STATUTS), 0), array_map('intval', $compter('SELECT statut, COUNT(*) FROM candidats WHERE archive_le IS NULL AND supprime_le IS NULL GROUP BY statut'))); + $parSeniorite = array_replace(array_fill_keys(array_keys(SENIORITES), 0), array_map('intval', $compter('SELECT seniorite, COUNT(*) FROM candidats WHERE archive_le IS NULL AND supprime_le IS NULL GROUP BY seniorite'))); + + // Temps passé dans chaque statut avant d'en sortir, reconstitué depuis l'historique + $codes = array_flip(STATUTS); + $durees = []; + $precedent = []; + foreach ($pdo->query( + "SELECT h.candidat_id, h.ancienne_valeur, h.cree_le, c.cree_le AS recu_le + FROM historique h JOIN candidats c ON c.id = h.candidat_id AND c.supprime_le IS NULL + WHERE h.action = 'changement_statut' ORDER BY h.candidat_id, h.cree_le, h.id" + ) as $h) { + $debut = $precedent[$h['candidat_id']] ?? strtotime($h['recu_le']); + $fin = strtotime($h['cree_le']); + // Code du statut ; les lignes antérieures à la version 1.3 portent encore le libellé français + $code = isset(STATUTS[$h['ancienne_valeur']]) ? $h['ancienne_valeur'] : ($codes[$h['ancienne_valeur']] ?? null); + // Les deux anciens états d'entretien comptent comme « Entretiens en cours » + if (isset(ANCIENS_STATUTS[$h['ancienne_valeur']]) || in_array($h['ancienne_valeur'], ANCIENS_STATUTS, true)) { + $code = 'entretiens'; + } + if ($code !== null && $fin >= $debut) { + $durees[$code][] = ($fin - $debut) / 86400; + } + $precedent[$h['candidat_id']] = $fin; + } + $delais = []; + foreach (STATUTS as $code => $libelle) { + if (!empty($durees[$code])) { + $delais[$code] = ['jours' => round(array_sum($durees[$code]) / count($durees[$code]), 1), 'passages' => count($durees[$code])]; + } + } + + return [ + 'total' => array_sum($parStatut), + 'par_statut' => $parStatut, + 'par_seniorite' => $parSeniorite, + 'archives' => (int) $pdo->query('SELECT COUNT(*) FROM candidats WHERE archive_le IS NOT NULL AND supprime_le IS NULL')->fetchColumn(), + 'recus_30j' => (int) $pdo->query('SELECT COUNT(*) FROM candidats WHERE cree_le >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND supprime_le IS NULL')->fetchColumn(), + 'en_parcours' => $parStatut['en_cours'] + $parStatut['entretiens'] + $parStatut['valide'], + 'entretiens_7j' => (int) $pdo->query('SELECT COUNT(*) FROM entretiens e JOIN candidats c ON c.id = e.candidat_id AND c.supprime_le IS NULL WHERE e.date_entretien >= CURDATE() AND e.date_entretien < DATE_ADD(CURDATE(), INTERVAL 8 DAY)')->fetchColumn(), + 'relances_dues' => nombre_relances_dues(), + 'besoins_ouverts' => (int) $pdo->query("SELECT COUNT(*) FROM besoins WHERE statut = 'ouvert'")->fetchColumn(), + 'a_echeance' => count(candidats_a_echeance()), + 'delais' => $delais, + 'technologies' => $pdo->query( + 'SELECT t.nom, COUNT(*) AS n FROM candidat_technologie ct JOIN technologies t ON t.id = ct.technologie_id + JOIN candidats c ON c.id = ct.candidat_id AND c.archive_le IS NULL AND c.supprime_le IS NULL + GROUP BY t.id, t.nom ORDER BY n DESC, t.nom LIMIT 12' + )->fetchAll(), + 'metiers' => $pdo->query("SELECT m.mot AS nom, COUNT(*) AS n FROM mots_cles m JOIN candidats c ON c.id = m.candidat_id AND c.archive_le IS NULL AND c.supprime_le IS NULL WHERE m.type = 'role' GROUP BY m.mot ORDER BY n DESC, m.mot LIMIT 8")->fetchAll(), + ]; +} + + +/** Détail d'une ligne du journal, dans la langue de l'interface. */ +function detail_journal(array $h): string +{ + $court = static fn (?string $v): string => mb_strimwidth((string) $v, 0, 90, '…'); + + if ($h['action'] === 'changement_statut') { + $statut = static function (?string $v): string { + [$code, $motif] = array_pad(explode('|', (string) $v, 2), 2, ''); + $libelle = libelle_statut($code); + return $motif !== '' ? $libelle . ' (' . $motif . ')' : $libelle; + }; + return $statut($h['ancienne_valeur']) . ' → ' . $court($statut($h['nouvelle_valeur'])); + } + + // Actions de suivi : le journal garde des codes séparés par des barres, traduits ici + $v = explode('|', (string) ($h['nouvelle_valeur'] ?? $h['ancienne_valeur'] ?? '')); + switch ($h['action']) { + case 'entretien': + $texte = t(TYPES_ENTRETIEN[$v[0]] ?? $v[0]) . (isset($v[1]) ? ', ' . date_fr($v[1], !str_ends_with($v[1], '00:00:00')) : '') + . (isset($v[2], AVIS_ENTRETIEN[$v[2]]) ? ' · ' . t('avis : %s', mb_strtolower(t(AVIS_ENTRETIEN[$v[2]]))) : ''); + return match ($h['champ']) { + 'modification' => t('%s (mis à jour)', $texte), + 'suppression' => t('%s (supprimé)', $texte), + default => $texte, + }; + case 'relance': + $texte = date_fr($v[0]) . ' · ' . $court(t_journal($v[1] ?? '')); + return match ($h['champ']) { + 'faite' => t('%s (faite)', $texte), + 'suppression' => t('%s (supprimée)', $texte), + default => $texte, + }; + case 'consentement': + return $h['champ'] === 'retrait' + ? t('Accord retiré') + : t('Accord du %s', date_fr($v[0])) . (($v[1] ?? '') !== '' ? ' · ' . $court($v[1]) : ''); + case 'besoin': + $texte = $court($v[1] ?? ''); + return match ($h['champ']) { + 'creation' => t('%s (créé)', $texte), + 'modification' => t('%s (modifié)', $texte), + 'suppression' => t('%s (supprimé)', $texte), + 'retire' => t('%s (profil retiré)', $texte), + 'statut' => $texte . ' → ' . t(ETATS_BESOIN[$v[2] ?? ''] ?? ''), + default => $texte . ' → ' . t(ETATS_PROPOSITION[$v[2] ?? ''] ?? ''), + }; + case 'sauvegarde': + $texte = $court(t_journal($v[0])); + return match ($h['champ']) { + 'telechargement' => t('%s (téléchargée)', $texte), + 'suppression' => t('%s (copie supprimée)', $texte), + default => $texte, + }; + case 'archivage': + case 'desarchivage': + return ''; + case 'corbeille': + case 'restauration': + return $court($v[0]); + case 'suppression': + // champ : date de mise à la corbeille, quand le profil y est passé + $texte = $court(t_journal($v[0])); + return $h['champ'] && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $h['champ']) ? t('%s (à la corbeille depuis le %s)', $texte, date_fr($h['champ'])) : $texte; + case 'securite': + return match ($h['champ']) { + 'nouvelle_application' => t('%s : nouvelle application d\'authentification', $court($v[0])), + 'mot_de_passe' => t('%s : mot de passe changé', $court($v[0])), + 'codes_secours' => t('%s : nouveaux codes de secours', $court($v[0])), + 'sessions' => t('%s : autres sessions fermées', $court($v[0])), + default => $court($v[0]), + }; + case 'compte': + return match ($h['champ']) { + 'acceptation' => t('%s (demande acceptée)', $court($v[0])), + 'refus' => t('%s (demande refusée)', $court($v[0])), + 'desactivation' => t('%s (désactivé)', $court($v[0])), + 'reactivation' => t('%s (réactivé)', $court($v[0])), + 'invitation' => t('Invitation créée : %s', $court(t_journal($v[0]))), + 'invitation_annulee' => t('Invitation annulée'), + 'creation_invitation' => t('%s (compte créé par invitation)', $court($v[0])), + 'creation' => t('%s (compte créé)', $court($v[0])), + 'mot_de_passe' => t('%s (mot de passe changé)', $court($v[0])), + 'double_auth' => t('%s (double authentification remise à zéro)', $court($v[0])), + 'gestionnaire' => t('%s (gestion des comptes confiée)', $court($v[0])), + 'gestionnaire_retire' => t('%s (gestion des comptes retirée)', $court($v[0])), + 'deblocage' => t('%s (connexion débloquée)', $court($v[0])), + default => $court($v[0]), + }; + } + + $champ = $h['action'] === 'modification' && $h['champ'] ? t($h['champ']) . ' : ' : ''; + if ($h['ancienne_valeur'] !== null && $h['nouvelle_valeur'] !== null) { + return $champ . $court($h['ancienne_valeur']) . ' → ' . $court(t_journal($h['nouvelle_valeur'])); + } + $valeur = $h['nouvelle_valeur'] ?? $h['ancienne_valeur']; + return $valeur === null ? '' : $champ . $court(t_journal($valeur)); +} diff --git a/src/erreurs.php b/src/erreurs.php new file mode 100644 index 0000000..0dde4ed --- /dev/null +++ b/src/erreurs.php @@ -0,0 +1,98 @@ + date('Y-m-d H:i:s'), + 'ref' => $ref, + 'compte' => isset($_SESSION['uid']) ? (int) $_SESSION['uid'] : null, + 'page' => mb_substr((string) ($_GET['p'] ?? (PHP_SAPI === 'cli' ? 'cli' : 'kanban')), 0, 40), + 'methode' => (string) ($_SERVER['REQUEST_METHOD'] ?? 'CLI'), + 'type' => mb_substr($type, 0, 80), + 'message' => mb_substr($message, 0, 500), + 'ou' => basename($fichier) . ':' . $ligne, + ]; + error_log('Vivier [' . $ref . '] ' . $entree['type'] . ' : ' . $entree['message'] . ' (' . $entree['ou'] . ')'); + try { + $chemin = fichier_erreurs(); + $lignes = is_file($chemin) ? (file($chemin, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []) : []; + $lignes[] = json_encode($entree, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + file_put_contents($chemin, implode("\n", array_slice($lignes, -ERREURS_MAX)) . "\n", LOCK_EX); + @chmod($chemin, 0600); + } catch (Throwable) { + // Le journal du serveur garde la trace + } + return $ref; +} + +/** Page d'erreur sobre, avec la référence. */ +function afficher_erreur(string $ref): void +{ + if (PHP_SAPI === 'cli') { + fwrite(STDERR, "Erreur, référence $ref\n"); + return; + } + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/html; charset=utf-8'); + } + while (ob_get_level() > 0) { + ob_end_clean(); + } + if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'fetch') { + echo json_encode(['ok' => false, 'erreur' => t('Une erreur est survenue (référence %s).', $ref)], JSON_UNESCAPED_UNICODE); + return; + } + echo '' . e(t('Erreur')) . ' · Vivier' + . '

' . e(t('Une erreur est survenue')) . '

' + . '

' . e(t('L\'action n\'a pas abouti. Revenez en arrière et réessayez. Si l\'erreur persiste, prévenez le gestionnaire de Vivier.')) . '

' + . '

' . e(t('Référence à lui donner : %s', $ref)) . '

' + . '

' . e(t('Retour au Kanban')) . '

'; +} + +/** Erreurs notées, les plus récentes d'abord. */ +function erreurs_notees(): array +{ + $chemin = fichier_erreurs(); + if (!is_file($chemin)) { + return []; + } + $liste = []; + foreach (file($chemin, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) { + $e = json_decode($l, true); + if (is_array($e)) { + $liste[] = $e; + } + } + return array_reverse($liste); +} + +function vider_erreurs(): void +{ + $chemin = fichier_erreurs(); + if (is_file($chemin)) { + @unlink($chemin); + } +} diff --git a/src/europass.php b/src/europass.php new file mode 100644 index 0000000..63b14bf --- /dev/null +++ b/src/europass.php @@ -0,0 +1,186 @@ + 'Compréhension orale', + 'lecture' => 'Compréhension écrite', + 'production_orale' => 'Expression orale en continu', + 'interaction_orale' => 'Interaction orale', + 'ecriture' => 'Écrit', +]; + +/** Une date « AAAA-MM » ou « AAAA-MM-JJ » au format du CV : 09/2004 ; une année seule reste telle quelle. */ +function date_europass(?string $d): string +{ + if ($d === null || $d === '') { + return ''; + } + if (preg_match('/^(\d{4})-(\d{2})/', $d, $m)) { + return $m[2] . '/' . $m[1]; + } + return (string) $d; +} + +/** + * @param array $c fiche complète (candidat()) + * @param array $options anonyme (bool), photo (bool), coordonnees (bool) + * @param ?array $tr contenu traduit (paquet_traduction()), ou null pour le texte d'origine + */ +function donnees_europass(array $c, array $options, string $langue = 'fr', ?array $tr = null): array +{ + $anonyme = !empty($options['anonyme']); + $L = static fn (string $cle, mixed ...$args): string => $args ? vsprintf(traduire($cle, $langue), $args) : traduire($cle, $langue); + $txt = static fn (?string $t): string => $anonyme ? anonymiser_texte($t, $c) : (string) $t; + $lignes = static fn (string $t): array => array_values(array_filter(array_map( + static fn ($l) => trim((string) preg_replace('/^[\s•\-–*·]+/u', '', $l)), preg_split('/\R/u', $t) ?: [] + ), 'strlen')); + $tr ??= []; + $ref = reference_profil($c); + $maj = static fn (string $t): string => mb_strtoupper($t, 'UTF-8'); + + // En-tête : nom et coordonnées + $coordonnees = []; + if (!$anonyme && !empty($options['coordonnees'])) { + $adresse = implode(', ', array_filter([$c['rue'], trim(($c['code_postal'] ?? '') . ' ' . ($c['ville'] ?? '')), $c['pays']])); + if ($c['email']) { + $coordonnees[] = ['libelle' => $L('Adresse électronique'), 'valeur' => $c['email'], 'lien' => 'mailto:' . $c['email']]; + } + if ($c['telephone']) { + $coordonnees[] = ['libelle' => $L('Numéro de téléphone'), 'valeur' => $c['telephone'], 'lien' => null]; + } + foreach ($c['liens'] as $l) { + if ($l['type'] === 'linkedin') { + $coordonnees[] = ['libelle' => 'LinkedIn', 'valeur' => $l['url'], 'lien' => $l['url']]; + } + } + if ($adresse !== '') { + $coordonnees[] = ['libelle' => $L('Adresse'), 'valeur' => $adresse, 'lien' => null]; + } + } + + // Éducation et formation : dates, lieu, diplôme, établissement + $formations = []; + foreach ($c['formations'] as $f) { + $debut = date_europass($f['date_debut'] ?? null); + $fin = date_europass($f['date_fin'] ?? null) ?: ($f['annee_fin'] ? (string) $f['annee_fin'] : ''); + $formations[] = [ + 'dates' => $debut !== '' && $fin !== '' ? $debut . ' – ' . $fin : ($fin !== '' ? $fin : $debut), + 'lieu' => (string) ($f['lieu'] ?? ''), + 'diplome' => $maj($tr['formations'][$f['id']] ?? $f['diplome']), + 'etablissement' => (string) $f['etablissement'], + ]; + } + + // Expérience professionnelle + $experiences = []; + foreach ($c['experiences'] as $x) { + $t = $tr['experiences'][$x['id']] ?? []; + $experiences[] = [ + 'employeur' => $maj((string) ($x['employeur'] ?: $x['client'] ?: '')), + 'lieu' => $maj((string) $x['lieu']), + 'poste' => $maj($txt($t['poste'] ?? $x['poste'])), + 'dates' => date_europass($x['date_debut']) . ' – ' . ($x['date_fin'] ? date_europass($x['date_fin']) : $maj($L('en cours'))), + 'details' => array_values(array_filter([ + $x['client'] && $x['employeur'] ? [$L('Client'), $x['client']] : null, + $x['projet'] ? [$L('Projet'), $txt($x['projet'])] : null, + ($t['mission'] ?? $x['mission']) ? [$L('Mission'), $txt($t['mission'] ?? $x['mission'])] : null, + $x['technologies'] ? [$L('Technologies'), (string) $x['technologies']] : null, + ($t['charge'] ?? $x['charge']) ? [$L('Charge'), $txt($t['charge'] ?? $x['charge'])] : null, + ])), + 'realisations' => $lignes($txt($t['description'] ?? $x['description'])), + ]; + } + + // Langues : maternelles d'un côté ; les autres avec la grille quand elle est connue, sinon le niveau global + $maternelles = []; + $autres = []; + foreach ($c['langues'] as $l) { + if ($l['niveau'] === 'natif') { + $maternelles[] = $maj($L($l['langue'])); + continue; + } + $grille = []; + foreach (array_keys(GRILLE_CECRL) as $k) { + $grille[$k] = (string) ($l[$k] ?? ''); + } + $autres[] = ['langue' => $maj($L($l['langue'])), 'grille' => array_filter($grille) ? $grille : null, 'niveau' => (string) ($l['niveau'] ?? '')]; + } + $avecGrille = (bool) array_filter(array_column($autres, 'grille')); + + // Compétences, groupées comme dans le modèle + $groupes = [ + 'Certifications' => array_map(static fn ($f) => (string) ($tr['certifications'][$f['id']] ?? $f['nom']), $c['certifications']), + 'Technique' => [], + 'Bases de données' => [], + 'Logiciels et outils' => [], + 'Cloud' => [], + 'Méthodes' => [], + 'Autres' => [], + ]; + $vers = ['langage' => 'Technique', 'framework' => 'Technique', 'base_de_donnees' => 'Bases de données', 'outil' => 'Logiciels et outils', + 'cloud' => 'Cloud', 'methode' => 'Méthodes', 'autre' => 'Autres']; + foreach ($c['technologies'] as $t) { + $groupes[$vers[$t['categorie']] ?? 'Autres'][] = $t['nom']; + } + $competences = []; + foreach ($groupes as $titre => $liste) { + if ($liste) { + $competences[$L($titre)] = $liste; + } + } + + $nomComplet = $c['prenom'] . ' ' . $c['nom']; + return [ + 'langue' => $langue, + 'deux_points' => $langue === 'fr' ? ' :' : ':', // espace avant les deux-points en français seulement + 'anonyme' => $anonyme, + 'nom' => $anonyme ? $L('Profil %s', $ref) : $nomComplet, + 'titre' => $txt($tr['titre'] ?? $c['titre']), + 'photo' => !$anonyme && !empty($options['photo']) && !empty($c['photo']) ? (string) $c['photo'] : null, + 'coordonnees' => $coordonnees, + 'resume' => $lignes($txt($tr['resume'] ?? $c['resume'])), + 'formations' => $formations, + 'experiences' => $experiences, + 'maternelles' => $maternelles, + 'autres' => $autres, + 'avec_grille' => $avecGrille, + 'competences' => $competences, + 'benevolat' => $lignes($txt($tr['benevolat'] ?? ($c['benevolat'] ?? ''))), + 'permis' => array_values(array_filter(array_map('trim', explode(',', (string) ($c['permis_conduire'] ?? ''))), 'strlen')), + 'libelles' => [ + 'apropos' => $L('À propos de moi'), + 'formation' => $L('Éducation et formation'), + 'experience' => $L('Expérience professionnelle'), + 'langues' => $L('Compétences linguistiques'), + 'maternelles' => $L('Langue(s) maternelle(s) :'), + 'autres' => $L('Autre(s) langue(s) :'), + 'comprehension' => $L('Compréhension'), + 'expression' => $L('Expression orale'), + 'ecrit' => $L('Écrit'), + 'grille' => array_map($L, GRILLE_CECRL), + 'niveaux' => $L('Niveaux : A1 et A2 : utilisateur de base ; B1 et B2 : utilisateur indépendant ; C1 et C2 : utilisateur expérimenté'), + 'niveau' => $L('Niveau'), + 'competences' => $L('Compétences'), + 'benevolat' => $L('Bénévolat'), + 'permis' => $L('Permis de conduire'), + 'permis_ligne' => $L('Permis de conduire :'), + 'realisations' => $L('Rôle et réalisations principales :'), + ], + 'fichier' => 'CV Europass - ' . ($anonyme ? $ref : $nomComplet) . ($langue !== 'fr' ? ' (' . strtoupper($langue) . ')' : ''), + ]; +} diff --git a/src/extraction.php b/src/extraction.php new file mode 100644 index 0000000..7d4ce11 --- /dev/null +++ b/src/extraction.php @@ -0,0 +1,53 @@ +open($chemin) !== true) { + throw new RuntimeException(t('Le fichier est illisible ou endommagé.')); + } + $texte = ''; + // Parcours dans l'ordre des motifs : l'en-tête sort avant le corps, quel que soit l'ordre dans l'archive + foreach ($parties as $motif) { + for ($i = 0; $i < $zip->numFiles; $i++) { + $nom = (string) $zip->getNameIndex($i); + if (preg_match($motif, $nom)) { + $xml = (string) $zip->getFromIndex($i); + $xml = preg_replace('#<(w:tab|text:tab)\b[^>]*/>#', "\t", $xml) ?? $xml; + $xml = preg_replace('#<(w:br|w:cr|text:line-break)\b[^>]*/>#', "\n", $xml) ?? $xml; + $xml = preg_replace('##', "\n", $xml) ?? $xml; + $texte .= html_entity_decode(strip_tags($xml), ENT_QUOTES | ENT_XML1, 'UTF-8') . "\n"; + } + } + } + $zip->close(); + return $texte; + }; + + $texte = match ($extension) { + 'pdf' => null, + 'md', 'txt' => (string) file_get_contents($chemin), + // L'en-tête et le pied de page portent souvent les coordonnées + 'docx' => $lireXml($chemin, ['#^word/header\d*\.xml$#', '#^word/document\.xml$#', '#^word/footer\d*\.xml$#'], ['w:p']), + 'odt' => $lireXml($chemin, ['#^content\.xml$#'], ['text:p', 'text:h']), + default => throw new RuntimeException(t('L\'analyse automatique lit les fichiers PDF, DOCX, ODT, MD et TXT. Convertissez ce fichier en PDF.')), + }; + if ($texte === null) { + return null; + } + if (!mb_check_encoding($texte, 'UTF-8')) { + $texte = mb_convert_encoding($texte, 'UTF-8', 'Windows-1252'); + } + $texte = trim(preg_replace("/\n{3,}/", "\n\n", preg_replace('/[ \t]+\n/', "\n", $texte) ?? $texte) ?? $texte); + if (mb_strlen($texte) < 200) { + throw new RuntimeException(t('Le fichier contient trop peu de texte. S\'il s\'agit d\'un document scanné, chargez-le en PDF.')); + } + return mb_substr($texte, 0, 150000); +} diff --git a/src/i18n.php b/src/i18n.php new file mode 100644 index 0000000..eeabfdb --- /dev/null +++ b/src/i18n.php @@ -0,0 +1,106 @@ +.php qui renvoie un tableau « texte français => traduction ». Une clé absente + * s'affiche en français : une traduction incomplète ne casse rien. + */ + +/** Langues proposées pour l'interface. Ajouter une ligne et le fichier lang/.php suffit. */ +const LANGUES_INTERFACE = ['fr' => 'Français', 'en' => 'English']; + +/** Langues proposées pour les CV générés. */ +const LANGUES_CV = ['fr' => 'Français', 'en' => 'English', 'de' => 'Deutsch']; + +function langue_courante(?string $definir = null): string +{ + static $langue = 'fr'; + if ($definir !== null && isset(LANGUES_INTERFACE[$definir])) { + $langue = $definir; + } + return $langue; +} + +function dictionnaire(string $langue): array +{ + static $cache = []; + if (!isset($cache[$langue])) { + $fichier = VIVIER_RACINE . '/lang/' . $langue . '.php'; + $cache[$langue] = $langue !== 'fr' && preg_match('/^[a-z]{2}$/', $langue) && is_file($fichier) ? (array) require $fichier : []; + } + return $cache[$langue]; +} + +/** Traduit un texte français dans la langue demandée. */ +function traduire(string $cle, string $langue): string +{ + return $langue === 'fr' ? $cle : (dictionnaire($langue)[$cle] ?? $cle); +} + +/** Traduit dans la langue de l'interface. Les arguments remplacent les %s et %d du texte. */ +function t(string $cle, mixed ...$args): string +{ + $texte = traduire($cle, langue_courante()); + return $args ? vsprintf($texte, $args) : $texte; +} + +/** Accord en nombre : tn($n, '%d profil', '%d profils'). */ +function tn(int $n, string $singulier, string $pluriel): string +{ + return sprintf(t($n > 1 ? $pluriel : $singulier), $n); +} + +/** Nombre à une décimale au plus, avec le séparateur de la langue : 9,1 en français, 9.1 en anglais. */ +function nombre(mixed $n, ?string $langue = null): string +{ + $langue ??= langue_courante(); + $texte = rtrim(rtrim(number_format((float) $n, 1, '.', ''), '0'), '.'); + return $langue === 'en' ? $texte : str_replace('.', ',', $texte); +} + +/** « 9,1 ans », « 1 an », « 9.1 years » */ +function annees(mixed $n, ?string $langue = null): string +{ + $langue ??= langue_courante(); + return sprintf(traduire((float) $n >= 2 ? '%s ans' : '%s an', $langue), nombre($n, $langue)); +} + +/** + * Traduit un texte du journal, enregistré en français. + * Les textes à partie variable sont reconnus par leur forme. + */ +function t_journal(?string $texte): string +{ + if ($texte === null || $texte === '' || langue_courante() === 'fr') { + return (string) $texte; + } + $dico = dictionnaire(langue_courante()); + if (isset($dico[$texte])) { + return $dico[$texte]; + } + foreach ([ + 'Import de %s', 'Analyse automatique de %s', 'Mise à jour depuis %s', 'Profil %s supprimé', 'Liste CSV (%s profils)', + 'Traduction en %s', 'Renouvellement du titre de séjour : il expire le %s', + ] as $forme) { + $motif = '/^' . str_replace('%s', '(.+)', preg_quote($forme, '/')) . '$/u'; + if (isset($dico[$forme]) && preg_match($motif, $texte, $m)) { + return sprintf($dico[$forme], t_journal($m[1])); + } + } + return $texte; +} + +/** Langue préférée du navigateur parmi celles de l'interface. */ +function langue_du_navigateur(): string +{ + foreach (explode(',', strtolower((string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''))) as $l) { + $code = substr(trim($l), 0, 2); + if (isset(LANGUES_INTERFACE[$code])) { + return $code; + } + } + return 'fr'; +} diff --git a/src/markdown.php b/src/markdown.php new file mode 100644 index 0000000..af79a67 --- /dev/null +++ b/src/markdown.php @@ -0,0 +1,87 @@ +$1', $t) ?? $t; + $t = preg_replace('/\*\*(.+?)\*\*/u', '$1', $t) ?? $t; + $t = preg_replace('/(?$1', $t) ?? $t; + return $t; + }; + $cellules = static fn (string $l): array => array_map('trim', explode('|', trim(trim($l), '|'))); + + $lignes = preg_split('/\R/u', $md) ?: []; + $html = ''; + $paragraphe = []; + $liste = null; // 'ul', 'ol' ou null + $fermer = static function () use (&$html, &$paragraphe, &$liste, $enLigne): void { + if ($paragraphe) { + $html .= '

' . implode('
', array_map($enLigne, $paragraphe)) . "

\n"; + $paragraphe = []; + } + if ($liste !== null) { + $html .= "\n"; + $liste = null; + } + }; + + for ($i = 0, $n = count($lignes); $i < $n; $i++) { + $l = rtrim($lignes[$i]); + + if (str_starts_with(ltrim($l), '```')) { + $fermer(); + $code = []; + for ($i++; $i < $n && !str_starts_with(ltrim($lignes[$i]), '```'); $i++) { + $code[] = $lignes[$i]; + } + $html .= '
' . htmlspecialchars(implode("\n", $code), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "
\n"; + } elseif (preg_match('/^(#{1,4})\s+(.*)$/', $l, $m)) { + $fermer(); + $niveau = strlen($m[1]) + 1; // le titre de page occupe déjà le niveau 1 + $html .= "" . $enLigne($m[2]) . "\n"; + } elseif (str_starts_with(ltrim($l), '|') && isset($lignes[$i + 1]) && preg_match('/^\s*\|?[\s:\-|]+\|[\s:\-|]*$/', $lignes[$i + 1])) { + $fermer(); + $html .= ''; + foreach ($cellules($l) as $c) { + $html .= ''; + } + $html .= "\n"; + for ($i += 2; $i < $n && str_starts_with(ltrim($lignes[$i]), '|'); $i++) { + $html .= ''; + foreach ($cellules($lignes[$i]) as $c) { + $html .= ''; + } + $html .= "\n"; + } + $i--; + $html .= "
' . $enLigne($c) . '
' . $enLigne($c) . '
\n"; + } elseif (preg_match('/^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/', $l, $m)) { + $type = $m[1] !== '' ? 'ul' : 'ol'; + if ($paragraphe || $liste !== $type) { + $fermer(); + $html .= "<$type>\n"; + $liste = $type; + } + $html .= '
  • ' . $enLigne($m[3]) . "
  • \n"; + } elseif (preg_match('/^\s*(-{3,}|\*{3,})\s*$/', $l)) { + $fermer(); + $html .= "
    \n"; + } elseif (trim($l) === '') { + $fermer(); + } else { + if ($liste !== null) { + $fermer(); + } + $paragraphe[] = trim($l); + } + } + $fermer(); + return $html; +} diff --git a/src/pays.php b/src/pays.php new file mode 100644 index 0000000..64d9450 --- /dev/null +++ b/src/pays.php @@ -0,0 +1,320 @@ + ['Andorre', 'Andorra'], + 'AE' => ['Émirats arabes unis', 'United Arab Emirates'], + 'AF' => ['Afghanistan', 'Afghanistan'], + 'AG' => ['Antigua-et-Barbuda', 'Antigua & Barbuda'], + 'AI' => ['Anguilla', 'Anguilla'], + 'AL' => ['Albanie', 'Albania'], + 'AM' => ['Arménie', 'Armenia'], + 'AO' => ['Angola', 'Angola'], + 'AQ' => ['Antarctique', 'Antarctica'], + 'AR' => ['Argentine', 'Argentina'], + 'AS' => ['Samoa américaines', 'American Samoa'], + 'AT' => ['Autriche', 'Austria'], + 'AU' => ['Australie', 'Australia'], + 'AW' => ['Aruba', 'Aruba'], + 'AX' => ['Îles Åland', 'Åland Islands'], + 'AZ' => ['Azerbaïdjan', 'Azerbaijan'], + 'BA' => ['Bosnie-Herzégovine', 'Bosnia & Herzegovina'], + 'BB' => ['Barbade', 'Barbados'], + 'BD' => ['Bangladesh', 'Bangladesh'], + 'BE' => ['Belgique', 'Belgium'], + 'BF' => ['Burkina Faso', 'Burkina Faso'], + 'BG' => ['Bulgarie', 'Bulgaria'], + 'BH' => ['Bahreïn', 'Bahrain'], + 'BI' => ['Burundi', 'Burundi'], + 'BJ' => ['Bénin', 'Benin'], + 'BL' => ['Saint-Barthélemy', 'St. Barthélemy'], + 'BM' => ['Bermudes', 'Bermuda'], + 'BN' => ['Brunei', 'Brunei'], + 'BO' => ['Bolivie', 'Bolivia'], + 'BQ' => ['Pays-Bas caribéens', 'Caribbean Netherlands'], + 'BR' => ['Brésil', 'Brazil'], + 'BS' => ['Bahamas', 'Bahamas'], + 'BT' => ['Bhoutan', 'Bhutan'], + 'BV' => ['Île Bouvet', 'Bouvet Island'], + 'BW' => ['Botswana', 'Botswana'], + 'BY' => ['Biélorussie', 'Belarus'], + 'BZ' => ['Belize', 'Belize'], + 'CA' => ['Canada', 'Canada'], + 'CC' => ['Îles Cocos', 'Cocos (Keeling) Islands'], + 'CD' => ['Congo-Kinshasa', 'Congo - Kinshasa'], + 'CF' => ['République centrafricaine', 'Central African Republic'], + 'CG' => ['Congo-Brazzaville', 'Congo - Brazzaville'], + 'CH' => ['Suisse', 'Switzerland'], + 'CI' => ['Côte d’Ivoire', 'Côte d’Ivoire'], + 'CK' => ['Îles Cook', 'Cook Islands'], + 'CL' => ['Chili', 'Chile'], + 'CM' => ['Cameroun', 'Cameroon'], + 'CN' => ['Chine', 'China'], + 'CO' => ['Colombie', 'Colombia'], + 'CR' => ['Costa Rica', 'Costa Rica'], + 'CU' => ['Cuba', 'Cuba'], + 'CV' => ['Cap-Vert', 'Cape Verde'], + 'CW' => ['Curaçao', 'Curaçao'], + 'CX' => ['Île Christmas', 'Christmas Island'], + 'CY' => ['Chypre', 'Cyprus'], + 'CZ' => ['Tchéquie', 'Czechia'], + 'DE' => ['Allemagne', 'Germany'], + 'DJ' => ['Djibouti', 'Djibouti'], + 'DK' => ['Danemark', 'Denmark'], + 'DM' => ['Dominique', 'Dominica'], + 'DO' => ['République dominicaine', 'Dominican Republic'], + 'DZ' => ['Algérie', 'Algeria'], + 'EC' => ['Équateur', 'Ecuador'], + 'EE' => ['Estonie', 'Estonia'], + 'EG' => ['Égypte', 'Egypt'], + 'EH' => ['Sahara occidental', 'Western Sahara'], + 'ER' => ['Érythrée', 'Eritrea'], + 'ES' => ['Espagne', 'Spain'], + 'ET' => ['Éthiopie', 'Ethiopia'], + 'FI' => ['Finlande', 'Finland'], + 'FJ' => ['Fidji', 'Fiji'], + 'FK' => ['Îles Malouines', 'Falkland Islands'], + 'FM' => ['Micronésie', 'Micronesia'], + 'FO' => ['Îles Féroé', 'Faroe Islands'], + 'FR' => ['France', 'France'], + 'GA' => ['Gabon', 'Gabon'], + 'GB' => ['Royaume-Uni', 'United Kingdom'], + 'GD' => ['Grenade', 'Grenada'], + 'GE' => ['Géorgie', 'Georgia'], + 'GF' => ['Guyane française', 'French Guiana'], + 'GG' => ['Guernesey', 'Guernsey'], + 'GH' => ['Ghana', 'Ghana'], + 'GI' => ['Gibraltar', 'Gibraltar'], + 'GL' => ['Groenland', 'Greenland'], + 'GM' => ['Gambie', 'Gambia'], + 'GN' => ['Guinée', 'Guinea'], + 'GP' => ['Guadeloupe', 'Guadeloupe'], + 'GQ' => ['Guinée équatoriale', 'Equatorial Guinea'], + 'GR' => ['Grèce', 'Greece'], + 'GS' => ['Géorgie du Sud-et-les Îles Sandwich du Sud', 'South Georgia & South Sandwich Islands'], + 'GT' => ['Guatemala', 'Guatemala'], + 'GU' => ['Guam', 'Guam'], + 'GW' => ['Guinée-Bissau', 'Guinea-Bissau'], + 'GY' => ['Guyana', 'Guyana'], + 'HK' => ['R.A.S. chinoise de Hong Kong', 'Hong Kong SAR China'], + 'HM' => ['Îles Heard-et-MacDonald', 'Heard & McDonald Islands'], + 'HN' => ['Honduras', 'Honduras'], + 'HR' => ['Croatie', 'Croatia'], + 'HT' => ['Haïti', 'Haiti'], + 'HU' => ['Hongrie', 'Hungary'], + 'ID' => ['Indonésie', 'Indonesia'], + 'IE' => ['Irlande', 'Ireland'], + 'IL' => ['Israël', 'Israel'], + 'IM' => ['Île de Man', 'Isle of Man'], + 'IN' => ['Inde', 'India'], + 'IO' => ['Territoire britannique de l’océan Indien', 'British Indian Ocean Territory'], + 'IQ' => ['Irak', 'Iraq'], + 'IR' => ['Iran', 'Iran'], + 'IS' => ['Islande', 'Iceland'], + 'IT' => ['Italie', 'Italy'], + 'JE' => ['Jersey', 'Jersey'], + 'JM' => ['Jamaïque', 'Jamaica'], + 'JO' => ['Jordanie', 'Jordan'], + 'JP' => ['Japon', 'Japan'], + 'KE' => ['Kenya', 'Kenya'], + 'KG' => ['Kirghizstan', 'Kyrgyzstan'], + 'KH' => ['Cambodge', 'Cambodia'], + 'KI' => ['Kiribati', 'Kiribati'], + 'KM' => ['Comores', 'Comoros'], + 'KN' => ['Saint-Christophe-et-Niévès', 'St. Kitts & Nevis'], + 'KP' => ['Corée du Nord', 'North Korea'], + 'KR' => ['Corée du Sud', 'South Korea'], + 'KW' => ['Koweït', 'Kuwait'], + 'KY' => ['Îles Caïmans', 'Cayman Islands'], + 'KZ' => ['Kazakhstan', 'Kazakhstan'], + 'LA' => ['Laos', 'Laos'], + 'LB' => ['Liban', 'Lebanon'], + 'LC' => ['Sainte-Lucie', 'St. Lucia'], + 'LI' => ['Liechtenstein', 'Liechtenstein'], + 'LK' => ['Sri Lanka', 'Sri Lanka'], + 'LR' => ['Liberia', 'Liberia'], + 'LS' => ['Lesotho', 'Lesotho'], + 'LT' => ['Lituanie', 'Lithuania'], + 'LU' => ['Luxembourg', 'Luxembourg'], + 'LV' => ['Lettonie', 'Latvia'], + 'LY' => ['Libye', 'Libya'], + 'MA' => ['Maroc', 'Morocco'], + 'MC' => ['Monaco', 'Monaco'], + 'MD' => ['Moldavie', 'Moldova'], + 'ME' => ['Monténégro', 'Montenegro'], + 'MF' => ['Saint-Martin', 'St. Martin'], + 'MG' => ['Madagascar', 'Madagascar'], + 'MH' => ['Îles Marshall', 'Marshall Islands'], + 'MK' => ['Macédoine du Nord', 'North Macedonia'], + 'ML' => ['Mali', 'Mali'], + 'MM' => ['Myanmar (Birmanie)', 'Myanmar (Burma)'], + 'MN' => ['Mongolie', 'Mongolia'], + 'MO' => ['R.A.S. chinoise de Macao', 'Macao SAR China'], + 'MP' => ['Îles Mariannes du Nord', 'Northern Mariana Islands'], + 'MQ' => ['Martinique', 'Martinique'], + 'MR' => ['Mauritanie', 'Mauritania'], + 'MS' => ['Montserrat', 'Montserrat'], + 'MT' => ['Malte', 'Malta'], + 'MU' => ['Maurice', 'Mauritius'], + 'MV' => ['Maldives', 'Maldives'], + 'MW' => ['Malawi', 'Malawi'], + 'MX' => ['Mexique', 'Mexico'], + 'MY' => ['Malaisie', 'Malaysia'], + 'MZ' => ['Mozambique', 'Mozambique'], + 'NA' => ['Namibie', 'Namibia'], + 'NC' => ['Nouvelle-Calédonie', 'New Caledonia'], + 'NE' => ['Niger', 'Niger'], + 'NF' => ['Île Norfolk', 'Norfolk Island'], + 'NG' => ['Nigeria', 'Nigeria'], + 'NI' => ['Nicaragua', 'Nicaragua'], + 'NL' => ['Pays-Bas', 'Netherlands'], + 'NO' => ['Norvège', 'Norway'], + 'NP' => ['Népal', 'Nepal'], + 'NR' => ['Nauru', 'Nauru'], + 'NU' => ['Niue', 'Niue'], + 'NZ' => ['Nouvelle-Zélande', 'New Zealand'], + 'OM' => ['Oman', 'Oman'], + 'PA' => ['Panama', 'Panama'], + 'PE' => ['Pérou', 'Peru'], + 'PF' => ['Polynésie française', 'French Polynesia'], + 'PG' => ['Papouasie-Nouvelle-Guinée', 'Papua New Guinea'], + 'PH' => ['Philippines', 'Philippines'], + 'PK' => ['Pakistan', 'Pakistan'], + 'PL' => ['Pologne', 'Poland'], + 'PM' => ['Saint-Pierre-et-Miquelon', 'St. Pierre & Miquelon'], + 'PN' => ['Îles Pitcairn', 'Pitcairn Islands'], + 'PR' => ['Porto Rico', 'Puerto Rico'], + 'PS' => ['Territoires palestiniens', 'Palestinian Territories'], + 'PT' => ['Portugal', 'Portugal'], + 'PW' => ['Palaos', 'Palau'], + 'PY' => ['Paraguay', 'Paraguay'], + 'QA' => ['Qatar', 'Qatar'], + 'RE' => ['La Réunion', 'Réunion'], + 'RO' => ['Roumanie', 'Romania'], + 'RS' => ['Serbie', 'Serbia'], + 'RU' => ['Russie', 'Russia'], + 'RW' => ['Rwanda', 'Rwanda'], + 'SA' => ['Arabie saoudite', 'Saudi Arabia'], + 'SB' => ['Îles Salomon', 'Solomon Islands'], + 'SC' => ['Seychelles', 'Seychelles'], + 'SD' => ['Soudan', 'Sudan'], + 'SE' => ['Suède', 'Sweden'], + 'SG' => ['Singapour', 'Singapore'], + 'SH' => ['Sainte-Hélène', 'St. Helena'], + 'SI' => ['Slovénie', 'Slovenia'], + 'SJ' => ['Svalbard et Jan Mayen', 'Svalbard & Jan Mayen'], + 'SK' => ['Slovaquie', 'Slovakia'], + 'SL' => ['Sierra Leone', 'Sierra Leone'], + 'SM' => ['Saint-Marin', 'San Marino'], + 'SN' => ['Sénégal', 'Senegal'], + 'SO' => ['Somalie', 'Somalia'], + 'SR' => ['Suriname', 'Suriname'], + 'SS' => ['Soudan du Sud', 'South Sudan'], + 'ST' => ['Sao Tomé-et-Principe', 'São Tomé & Príncipe'], + 'SV' => ['Salvador', 'El Salvador'], + 'SX' => ['Saint-Martin (partie néerlandaise)', 'Sint Maarten'], + 'SY' => ['Syrie', 'Syria'], + 'SZ' => ['Eswatini', 'Eswatini'], + 'TC' => ['Îles Turques-et-Caïques', 'Turks & Caicos Islands'], + 'TD' => ['Tchad', 'Chad'], + 'TF' => ['Terres australes françaises', 'French Southern Territories'], + 'TG' => ['Togo', 'Togo'], + 'TH' => ['Thaïlande', 'Thailand'], + 'TJ' => ['Tadjikistan', 'Tajikistan'], + 'TK' => ['Tokelau', 'Tokelau'], + 'TL' => ['Timor oriental', 'Timor-Leste'], + 'TM' => ['Turkménistan', 'Turkmenistan'], + 'TN' => ['Tunisie', 'Tunisia'], + 'TO' => ['Tonga', 'Tonga'], + 'TR' => ['Turquie', 'Türkiye'], + 'TT' => ['Trinité-et-Tobago', 'Trinidad & Tobago'], + 'TV' => ['Tuvalu', 'Tuvalu'], + 'TW' => ['Taïwan', 'Taiwan'], + 'TZ' => ['Tanzanie', 'Tanzania'], + 'UA' => ['Ukraine', 'Ukraine'], + 'UG' => ['Ouganda', 'Uganda'], + 'UM' => ['Îles mineures éloignées des États-Unis', 'U.S. Outlying Islands'], + 'US' => ['États-Unis', 'United States'], + 'UY' => ['Uruguay', 'Uruguay'], + 'UZ' => ['Ouzbékistan', 'Uzbekistan'], + 'VA' => ['État de la Cité du Vatican', 'Vatican City'], + 'VC' => ['Saint-Vincent-et-les Grenadines', 'St. Vincent & Grenadines'], + 'VE' => ['Venezuela', 'Venezuela'], + 'VG' => ['Îles Vierges britanniques', 'British Virgin Islands'], + 'VI' => ['Îles Vierges des États-Unis', 'U.S. Virgin Islands'], + 'VN' => ['Viêt Nam', 'Vietnam'], + 'VU' => ['Vanuatu', 'Vanuatu'], + 'WF' => ['Wallis-et-Futuna', 'Wallis & Futuna'], + 'WS' => ['Samoa', 'Samoa'], + 'XK' => ['Kosovo', 'Kosovo'], + 'YE' => ['Yémen', 'Yemen'], + 'YT' => ['Mayotte', 'Mayotte'], + 'ZA' => ['Afrique du Sud', 'South Africa'], + 'ZM' => ['Zambie', 'Zambia'], + 'ZW' => ['Zimbabwe', 'Zimbabwe'], +]; + +/** Nom d'un pays dans la langue demandée (français ou anglais ; les autres langues reçoivent l'anglais). */ +function nom_pays(string $code, ?string $langue = null): string +{ + $langue ??= langue_courante(); + $p = PAYS[strtoupper($code)] ?? null; + return $p === null ? $code : ($langue === 'fr' ? $p[0] : $p[1]); +} + +/** Pays triés par nom dans la langue demandée : [code => nom]. */ +function liste_pays(?string $langue = null): array +{ + $liste = []; + foreach (array_keys(PAYS) as $code) { + $liste[$code] = nom_pays($code, $langue); + } + $cle = static fn (string $n): string => Recherche::normaliser($n); + uasort($liste, static fn ($a, $b) => strcmp($cle($a), $cle($b))); + return $liste; +} + +/** + * Lit une liste de nationalités saisie ou importée : codes ISO séparés par des virgules, sans doublon. + * @return string[] codes reconnus, dans l'ordre donné + */ +function codes_nationalites(mixed $valeur): array +{ + $brut = is_array($valeur) ? $valeur : preg_split('/[\s,;]+/', (string) $valeur); + $codes = []; + foreach ($brut ?: [] as $c) { + $c = strtoupper(trim((string) $c)); + if (isset(PAYS[$c]) && !in_array($c, $codes, true)) { + $codes[] = $c; + } + } + return array_slice($codes, 0, 4); +} + +/** + * Situation d'un profil vis-à-vis du permis de travail au Luxembourg, d'après ses nationalités. + * @return ?int 0 : au moins une nationalité UE, EEE ou suisse (pas de permis) ; 1 : aucune ; null : nationalité inconnue + */ +function hors_ue(array $codes): ?int +{ + if (!$codes) { + return null; + } + return array_intersect($codes, PAYS_LIBRE_CIRCULATION) ? 0 : 1; +} diff --git a/src/securite.php b/src/securite.php new file mode 100644 index 0000000..b15f59b --- /dev/null +++ b/src/securite.php @@ -0,0 +1,751 @@ + ltrim($g, '0') ?: '0', $groupes)) . '::/48'; +} + +function navigateur(): string +{ + return mb_substr(trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')), 0, 255); +} + +/** Vrai si la requête arrive en HTTPS, directement ou derrière le relais de l'hébergeur. */ +function est_https(): bool +{ + return (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') + || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443 + || strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'; +} + +// --------------------------------------------------------------------------- +// Chiffrement des clés de double authentification +// La clé de chiffrement vit dans config/, hors de la base : une sauvegarde de la base seule ne livre pas les clés. +// --------------------------------------------------------------------------- + +/** Clé de chiffrement : config « cle_secrets », sinon config/cle-secrets.php, créé au premier besoin. */ +function cle_secrets(): string +{ + static $cle = null; + if ($cle !== null) { + return $cle; + } + $texte = (string) (config('cle_secrets') ?? ''); + if ($texte === '') { + $fichier = VIVIER_RACINE . '/config/cle-secrets.php'; + if (is_file($fichier)) { + $texte = (string) (require $fichier); + } else { + $texte = base64_encode(random_bytes(32)); + $contenu = "prepare('UPDATE utilisateurs SET codes_secours = ? WHERE id = ?')->execute([json_encode($empreintes), $utilisateurId]); + return $codes; +} + +/** Utilise un code de secours : vrai s'il est valide. Il est rayé de la liste. */ +function utiliser_code_secours(array $u, string $saisie): bool +{ + $saisie = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '', $saisie)); + if (strlen($saisie) !== 8) { + return false; + } + $empreintes = json_decode((string) ($u['codes_secours'] ?? '[]'), true) ?: []; + foreach ($empreintes as $i => $h) { + if (password_verify($saisie, $h)) { + unset($empreintes[$i]); + db()->prepare('UPDATE utilisateurs SET codes_secours = ? WHERE id = ?')->execute([json_encode(array_values($empreintes)), $u['id']]); + return true; + } + } + return false; +} + +function codes_secours_restants(array $u): int +{ + return count(json_decode((string) ($u['codes_secours'] ?? '[]'), true) ?: []); +} + +/** Clé de double authentification d'un compte, déchiffrée. null : à activer (jamais activée, remise à zéro ou clé illisible). */ +function cle_totp(array $u): ?string +{ + if (empty($u['totp_active_le'])) { + return null; + } + return dechiffrer($u['totp_secret'] ?? null); +} + +/** + * Vérifie le code d'une application ou un code de secours pour un compte. + * @return ?string « application » ou « secours » si le code est bon, null sinon + */ +function verifier_second_facteur(array $u, string $saisie): ?string +{ + $cle = cle_totp($u); + if ($cle !== null) { + $pas = Totp::verifier($cle, $saisie, $u['totp_dernier_pas'] !== null ? (int) $u['totp_dernier_pas'] : null); + if ($pas !== null) { + db()->prepare('UPDATE utilisateurs SET totp_dernier_pas = ? WHERE id = ?')->execute([$pas, $u['id']]); + return 'application'; + } + } + return utiliser_code_secours($u, $saisie) ? 'secours' : null; +} + +/** Active la double authentification avec une clé confirmée par un premier code. */ +function activer_double_authentification(int $utilisateurId, string $cle, int $pas): void +{ + db()->prepare('UPDATE utilisateurs SET totp_secret = ?, totp_active_le = NOW(), totp_dernier_pas = ? WHERE id = ?') + ->execute([chiffrer($cle), $pas, $utilisateurId]); +} + +/** Remet à zéro la double authentification d'un compte : la personne la réactive à sa prochaine connexion. */ +function reinitialiser_double_authentification(int $utilisateurId): void +{ + db()->prepare('UPDATE utilisateurs SET totp_secret = NULL, totp_active_le = NULL, totp_dernier_pas = NULL, codes_secours = NULL, + session_version = session_version + 1 WHERE id = ?')->execute([$utilisateurId]); +} + +// --------------------------------------------------------------------------- +// Mots de passe +// --------------------------------------------------------------------------- + +/** Argon2id quand PHP le propose, sinon bcrypt. */ +function algorithme_mot_de_passe(): string|int|null +{ + return defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT; +} + +function hacher_mot_de_passe(string $mdp): string +{ + return password_hash($mdp, algorithme_mot_de_passe()); +} + +/** + * Contrôle un nouveau mot de passe : longueur, puis présence dans les fuites connues. + * @throws InvalidArgumentException + */ +function controler_mot_de_passe(string $mdp): void +{ + if (mb_strlen($mdp) < LONGUEUR_MOT_DE_PASSE) { + throw new InvalidArgumentException(t('Le mot de passe doit compter au moins %d caractères.', LONGUEUR_MOT_DE_PASSE)); + } + if (mb_strlen($mdp) > LONGUEUR_MAX_MOT_DE_PASSE) { + throw new InvalidArgumentException(t('Le mot de passe compte au plus %d caractères.', LONGUEUR_MAX_MOT_DE_PASSE)); + } + if (mot_de_passe_fuite($mdp) === true) { + throw new InvalidArgumentException(t('Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.')); + } +} + +/** + * Le mot de passe figure-t-il dans les fuites recensées par le service Pwned Passwords ? + * Seuls les 5 premiers caractères de son empreinte SHA-1 quittent le serveur (k-anonymat). + * @return ?bool null si le service n'a pas répondu : le mot de passe est alors accepté + */ +function mot_de_passe_fuite(string $mdp): ?bool +{ + if ((config('securite')['mots_de_passe_fuites'] ?? true) === false || !function_exists('curl_init')) { + return null; + } + $sha = strtoupper(sha1($mdp)); + $service = (string) (config('securite')['service_fuites'] ?? 'https://api.pwnedpasswords.com/range/'); // autre adresse : pour les tests + $ch = curl_init($service . substr($sha, 0, 5)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 4, + CURLOPT_HTTPHEADER => ['Add-Padding: true', 'User-Agent: Vivier'], + ]); + $reponse = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if (!is_string($reponse) || $code !== 200) { + return null; + } + $suffixe = substr($sha, 5); + foreach (preg_split('/\R/', $reponse) ?: [] as $ligne) { + [$s, $n] = array_pad(explode(':', trim($ligne)), 2, '0'); + if ($s === $suffixe && (int) $n > 0) { + return true; + } + } + return false; +} + +/** Vérifie le mot de passe du compte connecté, avant une action sensible. @throws InvalidArgumentException */ +function confirmer_mot_de_passe(string $saisie): void +{ + $moi = utilisateur(); + $identifiant = (string) ($moi['identifiant'] ?? ''); + // Les essais comptent comme à la connexion : une session volée ne permet pas de deviner le mot de passe + if (($minutes = minutes_blocage($identifiant)) > 0) { + throw new InvalidArgumentException(t('Trop d\'essais infructueux. Réessayez dans %d minutes.', $minutes)); + } + $req = db()->prepare('SELECT mot_de_passe FROM utilisateurs WHERE id = ?'); + $req->execute([$moi['id'] ?? 0]); + if ($saisie === '' || !password_verify($saisie, (string) $req->fetchColumn())) { + if ($saisie !== '') { + noter_echec($identifiant); + } + throw new InvalidArgumentException(t('Votre mot de passe est incorrect : action annulée.')); + } +} + +// --------------------------------------------------------------------------- +// Blocage des essais +// Deux compteurs : l'identifiant depuis une adresse (un tiers ne peut pas bloquer un compte depuis chez lui +// pour son propriétaire), et l'adresse seule (un poste qui essaie un mot de passe sur tous les identifiants). +// --------------------------------------------------------------------------- + +function cle_essais_compte(string $identifiant, ?string $ip = null): string +{ + return mb_substr('compte:' . mb_strtolower(trim($identifiant)) . '@' . ($ip ?? adresse_ip()), 0, 190); +} + +function cle_essais_ip(?string $ip = null): string +{ + return 'ip:' . ($ip ?? adresse_ip()); +} + +/** Minutes de blocage de la série n (1, 2, 3…) : 15, 30, puis 60. */ +function duree_blocage(int $serie): int +{ + return BLOCAGES_MINUTES[min($serie, count(BLOCAGES_MINUTES)) - 1]; +} + +/** + * Fin du blocage d'une clé de compte, ou null si elle n'est pas bloquée. + * Chaque cinquième échec ouvre un blocage, plus long à chaque série. Les échecs de plus de 24 heures ne comptent plus. + * @param int[] $moments horodatages des échecs, du plus ancien au plus récent + */ +function fin_blocage_compte(array $moments, int $maintenant): ?int +{ + $n = count($moments); + if ($n < ECHECS_SERIE) { + return null; + } + $serie = intdiv($n, ECHECS_SERIE); + $debut = $moments[$serie * ECHECS_SERIE - 1]; + $fin = $debut + 60 * duree_blocage($serie); + return $fin > $maintenant ? $fin : null; +} + +/** Fin du blocage d'une adresse : 20 échecs dans l'heure. Le blocage dure jusqu'à ce que le plus ancien sorte de l'heure. */ +function fin_blocage_ip(array $moments, int $maintenant): ?int +{ + $recents = array_values(array_filter($moments, static fn ($m) => $m > $maintenant - 3600)); + if (count($recents) < ECHECS_IP_HEURE) { + return null; + } + return $recents[count($recents) - ECHECS_IP_HEURE] + 3600; +} + +/** @return int[] */ +function moments_echecs(string $cle): array +{ + $req = db()->prepare('SELECT UNIX_TIMESTAMP(moment) FROM tentatives_connexion WHERE cle = ? AND moment > DATE_SUB(NOW(), INTERVAL 1 DAY) ORDER BY moment, id'); + $req->execute([$cle]); + return array_map('intval', $req->fetchAll(PDO::FETCH_COLUMN)); +} + +/** Minutes avant de pouvoir réessayer, ou 0 si la connexion est permise. */ +function minutes_blocage(string $identifiant): int +{ + db()->exec("DELETE FROM tentatives_connexion WHERE moment < DATE_SUB(NOW(), INTERVAL 1 DAY) AND cle NOT LIKE 'inscription:%'"); + $maintenant = time(); + $fins = array_filter([ + fin_blocage_compte(moments_echecs(cle_essais_compte($identifiant)), $maintenant), + fin_blocage_ip(moments_echecs(cle_essais_ip()), $maintenant), + ]); + return $fins ? (int) ceil((max($fins) - $maintenant) / 60) : 0; +} + +function noter_echec(string $identifiant): void +{ + $req = db()->prepare('INSERT INTO tentatives_connexion (cle) VALUES (?)'); + $req->execute([cle_essais_compte($identifiant)]); + $req->execute([cle_essais_ip()]); +} + +/** Après une connexion réussie, les échecs de cet identifiant depuis cette adresse sont oubliés. */ +function effacer_echecs(string $identifiant): void +{ + db()->prepare('DELETE FROM tentatives_connexion WHERE cle = ?')->execute([cle_essais_compte($identifiant)]); +} + +/** + * Blocages en cours, pour le gestionnaire. + * @return array + */ +function blocages_en_cours(): array +{ + $maintenant = time(); + $par = []; + foreach (db()->query("SELECT cle, UNIX_TIMESTAMP(moment) AS m FROM tentatives_connexion + WHERE moment > DATE_SUB(NOW(), INTERVAL 1 DAY) AND (cle LIKE 'compte:%' OR cle LIKE 'ip:%') ORDER BY moment, id") as $l) { + $par[$l['cle']][] = (int) $l['m']; + } + $liste = []; + foreach ($par as $cle => $moments) { + $compte = str_starts_with($cle, 'compte:'); + $fin = $compte ? fin_blocage_compte($moments, $maintenant) : fin_blocage_ip($moments, $maintenant); + if ($fin === null) { + continue; + } + [$identifiant, $ip] = $compte ? array_pad(explode('@', substr($cle, 7), 2), 2, '') : ['', substr($cle, 3)]; + $liste[] = ['cle' => $cle, 'type' => $compte ? 'compte' : 'ip', 'identifiant' => $identifiant, 'ip' => $ip, 'echecs' => count($moments), 'fin' => $fin]; + } + usort($liste, static fn ($a, $b) => $b['fin'] <=> $a['fin']); + return $liste; +} + +/** Lève un blocage : les échecs enregistrés pour cette clé sont effacés. */ +function debloquer(string $cle): bool +{ + if (!preg_match('/^(compte|ip):/', $cle)) { + return false; + } + $req = db()->prepare('DELETE FROM tentatives_connexion WHERE cle = ?'); + $req->execute([$cle]); + return $req->rowCount() > 0; +} + +// --------------------------------------------------------------------------- +// Journal des connexions +// --------------------------------------------------------------------------- + +const RESULTATS_CONNEXION = [ + 'reussie' => 'Connexion réussie', + 'mot_de_passe' => 'Mot de passe incorrect', + 'code' => 'Code incorrect', + 'bloquee' => 'Essai bloqué', + 'desactive' => 'Compte désactivé', + 'attente' => 'Demande en attente', +]; + +/** @return bool vrai si le compte se connecte depuis un réseau jamais vu (connexion réussie seulement) */ +function noter_connexion(?int $utilisateurId, string $identifiant, string $resultat): bool +{ + $ip = adresse_ip(); + $reseau = reseau_ip($ip); + $nouveau = false; + if ($resultat === 'reussie' && $utilisateurId !== null) { + $req = db()->prepare("SELECT COUNT(*) FROM connexions WHERE utilisateur_id = ? AND resultat = 'reussie'"); + $req->execute([$utilisateurId]); + $deja = (int) $req->fetchColumn(); + $req = db()->prepare("SELECT 1 FROM connexions WHERE utilisateur_id = ? AND reseau = ? AND resultat = 'reussie' LIMIT 1"); + $req->execute([$utilisateurId, $reseau]); + // La toute première connexion d'un compte ne déclenche pas d'alerte : aucun réseau n'est encore connu + $nouveau = $deja > 0 && !$req->fetchColumn(); + } + db()->prepare('INSERT INTO connexions (utilisateur_id, identifiant, ip, reseau, navigateur, resultat) VALUES (?, ?, ?, ?, ?, ?)') + ->execute([$utilisateurId, mb_substr($identifiant, 0, 190), $ip, $reseau, navigateur(), $resultat]); + return $nouveau; +} + +function purger_connexions(): void +{ + db()->exec('DELETE FROM connexions WHERE moment < DATE_SUB(NOW(), INTERVAL ' . CONNEXIONS_MOIS . ' MONTH)'); +} + +/** Dernières connexions, filtrables par compte et par résultat. */ +function connexions(int $utilisateurId = 0, string $resultat = '', int $limite = 500): array +{ + $ou = ['1 = 1']; + $params = []; + if ($utilisateurId) { + $ou[] = 'c.utilisateur_id = ?'; + $params[] = $utilisateurId; + } + if ($resultat === 'echecs') { + $ou[] = "c.resultat <> 'reussie'"; + } elseif (isset(RESULTATS_CONNEXION[$resultat])) { + $ou[] = 'c.resultat = ?'; + $params[] = $resultat; + } + $req = db()->prepare('SELECT c.*, u.nom_affiche FROM connexions c LEFT JOIN utilisateurs u ON u.id = c.utilisateur_id + WHERE ' . implode(' AND ', $ou) . ' ORDER BY c.moment DESC, c.id DESC LIMIT ' . $limite); + $req->execute($params); + return $req->fetchAll(); +} + +/** Navigateur et système, en clair : « Firefox, Windows ». */ +function navigateur_lisible(string $ua): string +{ + $nav = match (true) { + str_contains($ua, 'Edg/') => 'Edge', + str_contains($ua, 'OPR/') => 'Opera', + str_contains($ua, 'Firefox/') => 'Firefox', + str_contains($ua, 'Chrome/') => 'Chrome', + str_contains($ua, 'Safari/') => 'Safari', + str_contains($ua, 'curl') || str_contains($ua, 'python') => t('programme'), + default => '', + }; + $sys = match (true) { + str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS', + str_contains($ua, 'Android') => 'Android', + str_contains($ua, 'Windows') => 'Windows', + str_contains($ua, 'Mac OS') => 'macOS', + str_contains($ua, 'Linux') => 'Linux', + default => '', + }; + return implode(', ', array_filter([$nav, $sys])) ?: t('inconnu'); +} + +/** Email au gestionnaire : un compte gestionnaire vient de se connecter depuis un réseau jamais vu. */ +function alerter_nouveau_reseau(array $u): void +{ + $ip = adresse_ip(); + Sauvegarde::notifier( + 'Vivier : connexion depuis un nouveau réseau', + "Le compte gestionnaire « " . $u['identifiant'] . " » vient de se connecter à Vivier depuis un réseau jamais utilisé par ce compte.\r\n\r\n" + . 'Date : ' . date('d/m/Y à H:i') . "\r\n" + . 'Adresse IP : ' . $ip . "\r\n" + . 'Navigateur : ' . navigateur_lisible(navigateur()) . "\r\n\r\n" + . "Si c'est vous, il n'y a rien à faire.\r\n" + . "Sinon : changez le mot de passe de ce compte, remettez à zéro sa double authentification (menu Gestion, Comptes),\r\n" + . "puis consultez le journal des connexions (menu Gestion, Connexions).\r\n" + ); +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +/** Ouvre la session d'un compte après le mot de passe et le code. */ +function ouvrir_session(array $u): void +{ + session_regenerate_id(true); + unset($_SESSION['etape']); + $_SESSION['uid'] = (int) $u['id']; + $_SESSION['sv'] = (int) $u['session_version']; + $_SESSION['debut'] = time(); + $_SESSION['vu'] = time(); +} + +/** Ferme toutes les sessions d'un compte. Avec $garderCelleCi, la session en cours reste ouverte. */ +function fermer_sessions(int $utilisateurId, bool $garderCelleCi = false): void +{ + db()->prepare('UPDATE utilisateurs SET session_version = session_version + 1 WHERE id = ?')->execute([$utilisateurId]); + if ($garderCelleCi && (int) ($_SESSION['uid'] ?? 0) === $utilisateurId) { + $req = db()->prepare('SELECT session_version FROM utilisateurs WHERE id = ?'); + $req->execute([$utilisateurId]); + $_SESSION['sv'] = (int) $req->fetchColumn(); + session_regenerate_id(true); + } +} + +/** + * Étape de connexion en cours : le mot de passe est bon, il reste à changer le mot de passe, activer + * la double authentification ou saisir le code. + * @return ?array{uid:int, quand:int} + */ +function etape_connexion(): ?array +{ + $e = $_SESSION['etape'] ?? null; + if (!is_array($e) || time() - (int) ($e['quand'] ?? 0) > DUREE_ETAPE) { + unset($_SESSION['etape']); + return null; + } + return $e; +} + +function commencer_etape(int $utilisateurId): void +{ + session_regenerate_id(true); + $_SESSION['etape'] = ['uid' => $utilisateurId, 'quand' => time()]; +} + +/** Compte complet, actif ou non, pour les étapes de connexion. */ +function compte_par_id(int $id): ?array +{ + $req = db()->prepare('SELECT * FROM utilisateurs WHERE id = ?'); + $req->execute([$id]); + return $req->fetch() ?: null; +} + +/** Page suivante d'une connexion commencée : mot de passe à changer, double authentification à activer, ou code. */ +function suite_connexion(array $u): string +{ + if ((int) $u['changer_mot_de_passe'] === 1) { + return 'nouveau_mot_de_passe'; + } + return cle_totp($u) === null ? 'double_authentification' : 'verification'; +} + +// --------------------------------------------------------------------------- +// Invitations +// --------------------------------------------------------------------------- + +/** Crée une invitation et renvoie le jeton en clair : il ne figure qu'une fois, dans le lien remis à la personne. */ +function creer_invitation(string $nom): string +{ + $jeton = bin2hex(random_bytes(24)); + db()->prepare('INSERT INTO invitations (jeton, nom_affiche, cree_par, expire_le) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ' . INVITATION_HEURES . ' HOUR))') + ->execute([hash('sha256', $jeton), trim($nom) !== '' ? mb_substr(trim($nom), 0, 120) : null, utilisateur()['id']]); + journaliser(null, 'compte', 'invitation', null, trim($nom) !== '' ? trim($nom) : 'Invitation'); + return $jeton; +} + +/** Invitation valide pour ce jeton : ni utilisée, ni annulée, ni expirée. */ +function invitation(string $jeton): ?array +{ + if (!preg_match('/^[a-f0-9]{48}$/', $jeton)) { + return null; + } + $req = db()->prepare('SELECT * FROM invitations WHERE jeton = ? AND utilise_le IS NULL AND annule_le IS NULL AND expire_le > NOW()'); + $req->execute([hash('sha256', $jeton)]); + return $req->fetch() ?: null; +} + +/** Invitations encore utilisables, les plus récentes d'abord. */ +function invitations_ouvertes(): array +{ + return db()->query('SELECT i.*, u.nom_affiche AS par FROM invitations i JOIN utilisateurs u ON u.id = i.cree_par + WHERE i.utilise_le IS NULL AND i.annule_le IS NULL AND i.expire_le > NOW() ORDER BY i.cree_le DESC')->fetchAll(); +} + +function annuler_invitation(int $id): bool +{ + $req = db()->prepare('UPDATE invitations SET annule_le = NOW() WHERE id = ? AND utilise_le IS NULL AND annule_le IS NULL'); + $req->execute([$id]); + if ($req->rowCount() > 0) { + journaliser(null, 'compte', 'invitation_annulee', null, 'Invitation'); + return true; + } + return false; +} + +/** + * Crée le compte d'une personne invitée. Le compte est actif tout de suite ; la double authentification reste à activer. + * @throws InvalidArgumentException + */ +function accepter_invitation(array $inv, string $identifiant, string $nom, string $mdp): int +{ + $pdo = db(); + $pdo->beginTransaction(); + try { + $id = creer_utilisateur($identifiant, $nom, $mdp); + $req = $pdo->prepare('UPDATE invitations SET utilise_le = NOW(), utilisateur_id = ? WHERE id = ? AND utilise_le IS NULL'); + $req->execute([$id, $inv['id']]); + if ($req->rowCount() !== 1) { + throw new InvalidArgumentException(t('Ce lien d\'invitation vient d\'être utilisé.')); + } + journaliser(null, 'compte', 'creation_invitation', null, nettoyer_identifiant_sans_controle($identifiant), $id); + $pdo->commit(); + return $id; + } catch (Throwable $ex) { + $pdo->rollBack(); + throw $ex; + } +} + +function nettoyer_identifiant_sans_controle(string $identifiant): string +{ + return mb_strtolower(trim($identifiant)); +} + +/** Adresse complète d'une page de Vivier, pour un lien envoyé à quelqu'un. */ +function adresse_complete(string $page, array $params = []): string +{ + $base = trim((string) (config('adresse') ?? '')); + if ($base === '') { + $hote = preg_replace('/[^a-zA-Z0-9.\-:\[\]]/', '', (string) ($_SERVER['HTTP_HOST'] ?? 'localhost')) ?: 'localhost'; + $chemin = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php'))), '/'); + $base = (est_https() || config('https') ? 'https' : 'http') . '://' . $hote . $chemin; + } + return rtrim($base, '/') . '/' . url($page, $params); +} diff --git a/src/suivi.php b/src/suivi.php new file mode 100644 index 0000000..6196f8c --- /dev/null +++ b/src/suivi.php @@ -0,0 +1,325 @@ +prepare('UPDATE candidats SET archive_le = NOW(), archive_par = ? WHERE id = ?')->execute([utilisateur()['id'], $c['id']]); + } else { + // Le profil revient en fin de colonne + $req = db()->prepare('SELECT COALESCE(MAX(position_kanban), 0) + 1 FROM candidats WHERE statut = ? AND archive_le IS NULL AND supprime_le IS NULL'); + $req->execute([$c['statut']]); + db()->prepare('UPDATE candidats SET archive_le = NULL, archive_par = NULL, position_kanban = ? WHERE id = ?') + ->execute([(int) $req->fetchColumn(), $c['id']]); + } + journaliser((int) $c['id'], $archiver ? 'archivage' : 'desarchivage'); +} + +// --------------------------------------------------------------------------- +// Entretiens +// --------------------------------------------------------------------------- + +/** + * Contrôle la saisie d'un entretien. + * @return array{date_entretien:string,type:string,interlocuteur:?string,besoin_id:?int,avis:?string,compte_rendu:?string} + * @throws InvalidArgumentException + */ +function lire_entretien(array $saisie): array +{ + $date = date_saisie(trim((string) ($saisie['date'] ?? '')), 'Date'); + $heure = trim((string) ($saisie['heure'] ?? '')); + if ($heure !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $heure)) { + throw new InvalidArgumentException(t('Heure invalide.')); + } + $type = (string) ($saisie['type'] ?? ''); + if (!isset(TYPES_ENTRETIEN[$type])) { + throw new InvalidArgumentException(t('Type d\'entretien inconnu.')); + } + $avis = (string) ($saisie['avis'] ?? ''); + if ($avis !== '' && !isset(AVIS_ENTRETIEN[$avis])) { + throw new InvalidArgumentException(t('Avis inconnu.')); + } + $besoinId = (int) ($saisie['besoin_id'] ?? 0); + if ($besoinId > 0) { + $req = db()->prepare('SELECT 1 FROM besoins WHERE id = ?'); + $req->execute([$besoinId]); + if (!$req->fetchColumn()) { + throw new InvalidArgumentException(t('Besoin introuvable.')); + } + } + $texte = static fn (string $cle, int $max): ?string => ($v = trim((string) ($saisie[$cle] ?? ''))) === '' ? null : mb_substr($v, 0, $max); + return [ + 'date_entretien' => $date . ' ' . ($heure !== '' ? $heure : '00:00') . ':00', + 'type' => $type, + 'interlocuteur' => $texte('interlocuteur', 150), + 'besoin_id' => $besoinId ?: null, + 'avis' => $avis ?: null, + 'compte_rendu' => $texte('compte_rendu', 10000), + ]; +} + +/** @throws InvalidArgumentException */ +function ajouter_entretien(array $c, array $saisie): int +{ + $e = lire_entretien($saisie); + db()->prepare( + 'INSERT INTO entretiens (candidat_id, date_entretien, type, interlocuteur, besoin_id, avis, compte_rendu, cree_par) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([$c['id'], $e['date_entretien'], $e['type'], $e['interlocuteur'], $e['besoin_id'], $e['avis'], $e['compte_rendu'], utilisateur()['id']]); + $id = (int) db()->lastInsertId(); + // Le journal garde des codes : l'affichage les traduit + journaliser((int) $c['id'], 'entretien', 'ajout', null, $e['type'] . '|' . $e['date_entretien']); + return $id; +} + +function entretien(int $id): ?array +{ + $req = db()->prepare('SELECT * FROM entretiens WHERE id = ?'); + $req->execute([$id]); + return $req->fetch() ?: null; +} + +/** Complète un entretien après coup : date, avis, compte rendu. @throws InvalidArgumentException */ +function modifier_entretien(array $ancien, array $saisie): void +{ + $e = lire_entretien($saisie); + db()->prepare( + 'UPDATE entretiens SET date_entretien = ?, type = ?, interlocuteur = ?, besoin_id = ?, avis = ?, compte_rendu = ? WHERE id = ?' + )->execute([$e['date_entretien'], $e['type'], $e['interlocuteur'], $e['besoin_id'], $e['avis'], $e['compte_rendu'], $ancien['id']]); + journaliser((int) $ancien['candidat_id'], 'entretien', 'modification', null, $e['type'] . '|' . $e['date_entretien'] . ($e['avis'] ? '|' . $e['avis'] : '')); +} + +function supprimer_entretien(array $e): void +{ + db()->prepare('DELETE FROM entretiens WHERE id = ?')->execute([$e['id']]); + journaliser((int) $e['candidat_id'], 'entretien', 'suppression', $e['type'] . '|' . $e['date_entretien'], null); +} + +/** Entretiens prévus à partir d'aujourd'hui, profils archivés exclus. */ +function entretiens_a_venir(int $jours = 30): array +{ + $req = db()->prepare( + 'SELECT e.id, e.date_entretien, e.type, e.interlocuteur, c.id AS candidat_id, c.prenom, c.nom, c.titre, c.photo, b.client AS besoin_client, b.intitule AS besoin_intitule + FROM entretiens e JOIN candidats c ON c.id = e.candidat_id LEFT JOIN besoins b ON b.id = e.besoin_id + WHERE e.date_entretien >= CURDATE() AND e.date_entretien < DATE_ADD(CURDATE(), INTERVAL ? DAY) AND c.archive_le IS NULL AND c.supprime_le IS NULL + ORDER BY e.date_entretien, e.id' + ); + $req->execute([$jours]); + return $req->fetchAll(); +} + +/** Entretiens passés sans avis : à compléter. */ +function entretiens_sans_avis(): array +{ + return db()->query( + 'SELECT e.id, e.date_entretien, e.type, c.id AS candidat_id, c.prenom, c.nom, c.photo + FROM entretiens e JOIN candidats c ON c.id = e.candidat_id + WHERE e.date_entretien < CURDATE() AND e.avis IS NULL AND c.archive_le IS NULL AND c.supprime_le IS NULL + ORDER BY e.date_entretien DESC LIMIT 50' + )->fetchAll(); +} + +// --------------------------------------------------------------------------- +// Relances +// --------------------------------------------------------------------------- + +/** @throws InvalidArgumentException */ +function ajouter_relance(array $c, array $saisie): void +{ + $echeance = date_saisie(trim((string) ($saisie['echeance'] ?? '')), 'Échéance'); + $objet = mb_substr(trim((string) ($saisie['objet'] ?? '')), 0, 200); + if ($objet === '') { + throw new InvalidArgumentException(t('Indiquez l\'objet de la relance.')); + } + db()->prepare('INSERT INTO relances (candidat_id, echeance, objet, cree_par) VALUES (?, ?, ?, ?)') + ->execute([$c['id'], $echeance, $objet, utilisateur()['id']]); + journaliser((int) $c['id'], 'relance', 'ajout', null, $echeance . '|' . $objet); +} + +function relance(int $id): ?array +{ + $req = db()->prepare('SELECT * FROM relances WHERE id = ?'); + $req->execute([$id]); + return $req->fetch() ?: null; +} + +function terminer_relance(array $r): void +{ + if ($r['fait_le'] !== null) { + return; + } + db()->prepare('UPDATE relances SET fait_le = NOW(), fait_par = ? WHERE id = ?')->execute([utilisateur()['id'], $r['id']]); + journaliser((int) $r['candidat_id'], 'relance', 'faite', null, $r['echeance'] . '|' . $r['objet']); +} + +function supprimer_relance(array $r): void +{ + db()->prepare('DELETE FROM relances WHERE id = ?')->execute([$r['id']]); + journaliser((int) $r['candidat_id'], 'relance', 'suppression', $r['echeance'] . '|' . $r['objet'], null); +} + +/** Relances à faire, les plus anciennes d'abord. Profils archivés exclus. */ +function relances_ouvertes(): array +{ + return db()->query( + 'SELECT r.id, r.echeance, r.objet, r.auto, DATEDIFF(r.echeance, CURDATE()) AS jours, u.nom_affiche, + c.id AS candidat_id, c.prenom, c.nom, c.titre, c.statut, c.photo + FROM relances r JOIN candidats c ON c.id = r.candidat_id JOIN utilisateurs u ON u.id = r.cree_par + WHERE r.fait_le IS NULL AND c.archive_le IS NULL AND c.supprime_le IS NULL ORDER BY r.echeance, r.id' + )->fetchAll(); +} + +/** Relances dont la date est atteinte ou dépassée. */ +function nombre_relances_dues(): int +{ + return (int) db()->query( + 'SELECT COUNT(*) FROM relances r JOIN candidats c ON c.id = r.candidat_id + WHERE r.fait_le IS NULL AND r.echeance <= CURDATE() AND c.archive_le IS NULL AND c.supprime_le IS NULL' + )->fetchColumn(); +} + +// --------------------------------------------------------------------------- +// Consentement +// --------------------------------------------------------------------------- + +/** + * Enregistre l'accord du candidat pour la conservation de ses données, ou le retire si la date est vide. + * @throws InvalidArgumentException + */ +function enregistrer_consentement(array $c, string $date, string $note): void +{ + $date = trim($date); + $note = mb_substr(trim($note), 0, 200); + if ($date === '') { + if ($c['consentement_le'] === null) { + return; + } + db()->prepare('UPDATE candidats SET consentement_le = NULL, consentement_note = NULL WHERE id = ?')->execute([$c['id']]); + journaliser((int) $c['id'], 'consentement', 'retrait', $c['consentement_le'] . '|' . (string) $c['consentement_note'], null); + return; + } + $date = date_saisie($date, 'Date de l\'accord'); + if ($date > date('Y-m-d')) { + throw new InvalidArgumentException(t('La date de l\'accord ne peut pas être dans le futur.')); + } + db()->prepare('UPDATE candidats SET consentement_le = ?, consentement_note = ? WHERE id = ?')->execute([$date, $note !== '' ? $note : null, $c['id']]); + journaliser((int) $c['id'], 'consentement', 'accord', null, $date . '|' . $note); +} + +// --------------------------------------------------------------------------- +// Export des données d'un candidat (droit d'accès) +// --------------------------------------------------------------------------- + +/** + * Rassemble dans un zip tout ce que Vivier détient sur un candidat : données, documents, photo. + * @return string chemin du zip, à supprimer après envoi + */ +function exporter_donnees_candidat(array $c): string +{ + $pdo = db(); + $lire = static function (string $sql) use ($pdo, $c): array { + $r = $pdo->prepare($sql); + $r->execute([$c['id']]); + return $r->fetchAll(); + }; + $sansId = static fn (array $lignes): array => array_map(static function (array $l): array { + unset($l['id'], $l['candidat_id']); + return $l; + }, $lignes); + + $identite = array_intersect_key($c, array_flip([ + 'prenom', 'nom', 'email', 'telephone', 'rue', 'code_postal', 'ville', 'pays', 'nationalites', 'permis_travail', 'permis_expire_le', + 'titre', 'resume', 'disponibilite_code', 'disponibilite', 'disponible_le', 'benevolat', 'permis_conduire', + 'tjm', 'salaire_souhaite', 'mobilite', 'teletravail', 'niveau_etudes', 'encadrement', 'annees_experience', 'seniorite', + 'statut', 'motif_refus', 'archive_le', 'conserver_jusqu_au', 'consentement_le', 'consentement_note', 'cree_le', 'modifie_le', + ])); + $mots = []; + foreach ($c['mots_cles'] as $type => $liste) { + $mots[$type] = array_column($liste, 'mot'); + } + $donnees = [ + 'export' => ['reference' => reference_profil($c), 'date' => date('Y-m-d H:i'), 'responsable' => (string) (config('societe')['nom'] ?? '')], + 'profil' => $identite, + 'liens' => $c['liens'], + 'experiences' => $sansId($c['experiences']), + 'formations' => $sansId($c['formations']), + 'certifications' => $sansId($c['certifications']), + 'technologies' => $c['technologies'], + 'langues' => $c['langues'], + 'mots_cles' => $mots, + 'points_attention' => $c['points_attention'], + 'commentaires' => array_map(static fn ($m) => ['date' => $m['cree_le'], 'auteur' => $m['nom_affiche'], 'texte' => $m['texte']], $c['commentaires']), + 'entretiens' => array_map(static fn ($e) => [ + 'date' => $e['date_entretien'], 'type' => $e['type'], 'interlocuteur' => $e['interlocuteur'], 'avis' => $e['avis'], 'compte_rendu' => $e['compte_rendu'], + 'besoin' => $e['besoin_intitule'] ? $e['besoin_client'] . ' : ' . $e['besoin_intitule'] : null, + ], $c['entretiens']), + 'relances' => array_map(static fn ($r) => ['echeance' => $r['echeance'], 'objet' => $r['objet'], 'fait_le' => $r['fait_le']], $c['relances']), + 'besoins_clients' => array_map(static fn ($b) => ['client' => $b['client'], 'intitule' => $b['intitule'], 'etat' => $b['etat'], 'note' => $b['note']], $c['besoins']), + 'traductions' => $lire('SELECT langue, origine, cree_le, contenu FROM traductions WHERE candidat_id = ?'), + 'documents' => array_map(static fn ($d) => ['fichier' => $d['nom_fichier'], 'type' => $d['type'], 'ajoute_le' => $d['ajoute_le']], $c['documents']), + 'historique' => array_map(static fn ($h) => [ + 'date' => $h['cree_le'], 'compte' => $h['nom_affiche'], 'action' => $h['action'], 'champ' => $h['champ'], + 'ancienne_valeur' => $h['ancienne_valeur'], 'nouvelle_valeur' => $h['nouvelle_valeur'], + ], $c['historique']), + 'analyse_source' => json_decode((string) $c['json_source'], true), + ]; + + $chemin = dossier_stockage('tmp') . '/export-' . bin2hex(random_bytes(8)) . '.zip'; + $zip = new ZipArchive(); + if ($zip->open($chemin, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException(t('L\'archive n\'a pas pu être créée.')); + } + $zip->addFromString('donnees.json', (string) json_encode($donnees, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + $zip->addFromString('LISEZMOI.txt', + "Données détenues par " . ($donnees['export']['responsable'] ?: 'IT Club') . " dans l'outil Vivier\r\n" + . 'Profil ' . reference_profil($c) . ' : ' . $c['prenom'] . ' ' . $c['nom'] . "\r\n" + . 'Export du ' . date('d/m/Y H:i') . "\r\n\r\n" + . "donnees.json : toutes les données enregistrées, commentaires et historique compris.\r\n" + . "documents/ : les fichiers attachés au profil.\r\n" + . "photo.jpg : la photo, si le profil en a une.\r\n" + ); + $noms = []; + foreach ($lire('SELECT nom_fichier, chemin FROM documents WHERE candidat_id = ? ORDER BY id') as $d) { + $source = dossier_stockage() . '/' . $d['chemin']; + if (!is_file($source)) { + continue; + } + // Deux documents peuvent porter le même nom : le second reçoit un numéro + $nom = preg_replace('/[^\w.\- ()]+/u', '_', $d['nom_fichier']) ?: 'document'; + $n = $noms[$nom] = ($noms[$nom] ?? 0) + 1; + $zip->addFile($source, 'documents/' . ($n > 1 ? pathinfo($nom, PATHINFO_FILENAME) . " ($n)." . pathinfo($nom, PATHINFO_EXTENSION) : $nom)); + } + if (!empty($c['photo']) && is_file(dossier_stockage() . '/' . $c['photo'])) { + $zip->addFile(dossier_stockage() . '/' . $c['photo'], 'photo.jpg'); + } + $zip->close(); + journaliser((int) $c['id'], 'export', 'donnees', null, 'Export des données personnelles'); + return $chemin; +} + +/** Envoie un fichier temporaire au navigateur, puis le supprime. */ +function envoyer_fichier_temporaire(string $chemin, string $nom, string $mime): never +{ + header('Content-Type: ' . $mime); + header('Content-Length: ' . filesize($chemin)); + header('Content-Disposition: attachment; filename="' . preg_replace('/[^\w.\-]+/', '_', $nom) . '"'); + header('Cache-Control: private, no-store'); + while (ob_get_level() > 0) { + ob_end_clean(); + } + readfile($chemin); + @unlink($chemin); + exit; +} diff --git a/src/traduction.php b/src/traduction.php new file mode 100644 index 0000000..5916d00 --- /dev/null +++ b/src/traduction.php @@ -0,0 +1,335 @@ + (string) $c['titre'], + 'resume' => (string) $c['resume'], + 'disponibilite' => (string) $c['disponibilite'], + 'benevolat' => (string) ($c['benevolat'] ?? ''), + ], 'strlen'); + $points = array_values(array_filter(array_map('strval', $c['points_attention'] ?? []), 'strlen')); + if ($points) { + $paquet['points_attention'] = $points; + } + foreach ($c['experiences'] as $x) { + $e = array_filter([ + 'poste' => (string) $x['poste'], + 'description' => (string) $x['description'], + 'mission' => (string) ($x['mission'] ?? ''), + 'charge' => (string) ($x['charge'] ?? ''), + ], 'strlen'); + if ($e) { + $paquet['experiences'][(string) $x['id']] = $e; + } + } + foreach ($c['formations'] as $f) { + $paquet['formations'][(string) $f['id']] = (string) $f['diplome']; + } + foreach ($c['certifications'] as $f) { + $paquet['certifications'][(string) $f['id']] = (string) $f['nom']; + } + // Métiers : mot d'origine => mot traduit. Ils servent au tableau de bord et à la recherche, pas à la fiche. + $metiers = array_map('strval', array_column($c['mots_cles']['role'] ?? [], 'mot')); + if ($metiers) { + $paquet['metiers'] = array_combine($metiers, $metiers); + } + return $paquet; +} + +/** Empreinte du texte d'origine. Les métiers n'y entrent pas : ajouter un mot-clé ne périme pas la traduction de la fiche. */ +function empreinte_paquet(array $paquet): string +{ + unset($paquet['metiers']); + return sha1((string) json_encode($paquet, JSON_UNESCAPED_UNICODE)); +} + +/** + * Traduction enregistrée d'un profil. + * @return array{etat: string, contenu: ?array, origine: ?string, cree_le: ?string} etat : inutile, absente, a_jour ou perimee + */ +function traduction_profil(array $c, string $langue): array +{ + if (!traduction_necessaire($c, $langue)) { + return ['etat' => 'inutile', 'contenu' => null, 'origine' => null, 'cree_le' => null]; + } + $req = db()->prepare('SELECT contenu, empreinte, origine, cree_le FROM traductions WHERE candidat_id = ? AND langue = ?'); + $req->execute([$c['id'], $langue]); + $t = $req->fetch(); + if (!$t) { + return ['etat' => 'absente', 'contenu' => null, 'origine' => null, 'cree_le' => null]; + } + $aJour = $t['empreinte'] === empreinte_paquet(paquet_traduction($c)); + return ['etat' => $aJour ? 'a_jour' : 'perimee', 'contenu' => $aJour ? json_decode($t['contenu'], true) : null, 'origine' => $t['origine'], 'cree_le' => $t['cree_le']]; +} + +/** + * Reprend une traduction reçue sur la forme du paquet d'origine. Une partie manquante garde le texte d'origine. + * Fonction pure. + * @return array{0: array, 1: int} le paquet traduit et le nombre de textes traduits + */ +function fusionner_traduction(array $paquet, mixed $recu): array +{ + $traduits = 0; + $marcher = static function (mixed $origine, mixed $recu) use (&$marcher, &$traduits): mixed { + if (is_array($origine)) { + $sortie = []; + foreach ($origine as $k => $v) { + $sortie[$k] = $marcher($v, is_array($recu) ? ($recu[$k] ?? $recu[(string) $k] ?? null) : null); + } + return $sortie; + } + if (is_string($recu) && trim($recu) !== '') { + $traduits++; + return trim($recu); + } + return $origine; + }; + return [$marcher($paquet, $recu), $traduits]; +} + +/** + * Contrôle une traduction reçue et l'enregistre. Ne garde que les clés du paquet d'origine. + * @throws RuntimeException si la traduction ne correspond pas au profil + */ +function enregistrer_traduction(array $c, string $langue, mixed $contenu, string $origine, ?int $auteurId = null): void +{ + if (!is_array($contenu)) { + throw new RuntimeException(t('La traduction reçue est illisible.')); + } + $paquet = paquet_traduction($c); + [$propre, $traduits] = fusionner_traduction($paquet, $contenu); + if ($traduits === 0) { + throw new RuntimeException(t('La traduction reçue ne correspond pas à ce profil.')); + } + $auteur = $auteurId ?? (int) utilisateur()['id']; + db()->prepare( + 'REPLACE INTO traductions (candidat_id, langue, contenu, empreinte, origine, cree_par, cree_le) VALUES (?, ?, ?, ?, ?, ?, NOW())' + )->execute([$c['id'], $langue, json_encode($propre, JSON_UNESCAPED_UNICODE), empreinte_paquet($paquet), $origine, $auteur]); + db()->prepare('DELETE FROM traductions_a_faire WHERE candidat_id = ? AND langue = ?')->execute([$c['id'], $langue]); + journaliser((int) $c['id'], 'modification', 'Traduction', null, 'Traduction en ' . (['fr' => 'français', 'en' => 'anglais', 'de' => 'allemand'][$langue] ?? $langue), $auteur); + Recherche::indexer(db(), (int) $c['id']); +} + +/** Traduit tout de suite un profil par l'API Claude. @throws RuntimeException */ +function traduire_profil(array $c, string $langue, ?int $auteurId = null): void +{ + if (!Claude::configure()) { + throw new RuntimeException(t('La traduction automatique n\'est pas disponible.')); + } + enregistrer_traduction($c, $langue, Claude::traduire(paquet_traduction($c), langue_du_profil($c), $langue), 'automatique', $auteurId); +} + +// --------------------------------------------------------------------------- +// File d'attente +// --------------------------------------------------------------------------- + +/** + * Inscrit un profil dans la file pour chaque langue de l'interface où il lui manque une traduction à jour. + * Appelée après un import, une mise à jour depuis une analyse ou une modification de la fiche. + */ +function planifier_traduction(int $candidatId): void +{ + $c = candidat($candidatId); + if ($c === null) { + return; + } + $ajouter = db()->prepare('INSERT INTO traductions_a_faire (candidat_id, langue) VALUES (?, ?) + ON DUPLICATE KEY UPDATE essais = 0, erreur = NULL, verrou_le = NULL, ajoute_le = NOW()'); + $retirer = db()->prepare('DELETE FROM traductions_a_faire WHERE candidat_id = ? AND langue = ?'); + foreach (array_keys(LANGUES_INTERFACE) as $langue) { + $etat = traduction_profil($c, $langue)['etat']; + if ($etat === 'absente' || $etat === 'perimee') { + $ajouter->execute([$candidatId, $langue]); + } else { + $retirer->execute([$candidatId, $langue]); + } + } +} + +/** Inscrit tous les profils dans la file. @return int nombre de traductions à faire */ +function planifier_toutes_traductions(): int +{ + foreach (db()->query('SELECT id FROM candidats WHERE supprime_le IS NULL')->fetchAll(PDO::FETCH_COLUMN) as $id) { + planifier_traduction((int) $id); + } + return nombre_traductions_a_faire(); +} + +/** Traductions qui attendent et peuvent encore être tentées. */ +function nombre_traductions_a_faire(): int +{ + return (int) db()->query('SELECT COUNT(*) FROM traductions_a_faire f JOIN candidats c ON c.id = f.candidat_id AND c.supprime_le IS NULL + WHERE f.essais < ' . TRADUCTION_ESSAIS)->fetchColumn(); +} + +/** Traductions abandonnées après plusieurs échecs, avec leur dernière erreur. */ +function traductions_en_echec(): array +{ + return db()->query('SELECT f.*, c.prenom, c.nom FROM traductions_a_faire f JOIN candidats c ON c.id = f.candidat_id AND c.supprime_le IS NULL + WHERE f.essais >= ' . TRADUCTION_ESSAIS . ' ORDER BY c.nom, c.prenom')->fetchAll(); +} + +function relancer_traductions_en_echec(): void +{ + db()->exec('UPDATE traductions_a_faire SET essais = 0, erreur = NULL, verrou_le = NULL WHERE essais >= ' . TRADUCTION_ESSAIS); +} + +function profil_en_file(int $candidatId, string $langue): ?array +{ + $req = db()->prepare('SELECT * FROM traductions_a_faire WHERE candidat_id = ? AND langue = ?'); + $req->execute([$candidatId, $langue]); + return $req->fetch() ?: null; +} + +/** + * Traite la traduction suivante de la file. Un poste à la fois : la ligne est verrouillée pendant le travail. + * @return array{fait: bool, reste: int, erreur: ?string} + */ +function traduction_suivante(int $auteurId): array +{ + $pdo = db(); + // Une ligne verrouillée depuis plus de 5 minutes est reprise : le poste qui la traitait s'est arrêté + $ligne = $pdo->query('SELECT f.candidat_id, f.langue FROM traductions_a_faire f JOIN candidats c ON c.id = f.candidat_id AND c.supprime_le IS NULL + WHERE f.essais < ' . TRADUCTION_ESSAIS . ' AND (f.verrou_le IS NULL OR f.verrou_le < DATE_SUB(NOW(), INTERVAL 5 MINUTE)) + ORDER BY f.ajoute_le, f.candidat_id LIMIT 1')->fetch(); + if (!$ligne) { + return ['fait' => false, 'reste' => nombre_traductions_a_faire(), 'erreur' => null]; + } + $verrou = $pdo->prepare('UPDATE traductions_a_faire SET verrou_le = NOW() WHERE candidat_id = ? AND langue = ? + AND (verrou_le IS NULL OR verrou_le < DATE_SUB(NOW(), INTERVAL 5 MINUTE))'); + $verrou->execute([$ligne['candidat_id'], $ligne['langue']]); + if ($verrou->rowCount() !== 1) { + return ['fait' => false, 'reste' => nombre_traductions_a_faire(), 'erreur' => null]; // un autre poste l'a prise + } + $c = candidat((int) $ligne['candidat_id']); + $erreur = null; + try { + $etat = $c ? traduction_profil($c, $ligne['langue'])['etat'] : 'inutile'; + if ($etat === 'absente' || $etat === 'perimee') { + traduire_profil($c, $ligne['langue'], $auteurId); + } + $pdo->prepare('DELETE FROM traductions_a_faire WHERE candidat_id = ? AND langue = ?')->execute([$ligne['candidat_id'], $ligne['langue']]); + } catch (RuntimeException $ex) { + $erreur = $ex->getMessage(); + $pdo->prepare('UPDATE traductions_a_faire SET essais = essais + 1, erreur = ?, verrou_le = NULL WHERE candidat_id = ? AND langue = ?') + ->execute([mb_substr($erreur, 0, 500), $ligne['candidat_id'], $ligne['langue']]); + } + return ['fait' => $erreur === null, 'reste' => nombre_traductions_a_faire(), 'erreur' => $erreur]; +} + +// --------------------------------------------------------------------------- +// Affichage +// --------------------------------------------------------------------------- + +/** + * Fiche prête à afficher dans une langue : les textes traduits remplacent ceux d'origine quand la traduction est à jour. + * Ajoute « traduction » : état de la traduction (inutile, a_jour, absente, perimee) et « original » : vrai si rien n'est traduit. + */ +function profil_affiche(array $c, string $langue): array +{ + $tr = traduction_profil($c, $langue); + $c['traduction'] = $tr; + if ($tr['etat'] !== 'a_jour') { + return $c; + } + $t = $tr['contenu']; + foreach (['titre', 'resume', 'disponibilite', 'benevolat'] as $k) { + if (isset($t[$k]) && is_string($t[$k])) { + $c[$k] = $t[$k]; + } + } + if (isset($t['points_attention']) && is_array($t['points_attention'])) { + $c['points_attention'] = $t['points_attention']; + } + foreach ($c['experiences'] as $i => $x) { + foreach ((array) ($t['experiences'][$x['id']] ?? []) as $k => $v) { + if (in_array($k, ['poste', 'description', 'mission', 'charge'], true) && is_string($v)) { + $c['experiences'][$i][$k] = $v; + } + } + } + foreach ($c['formations'] as $i => $f) { + if (isset($t['formations'][$f['id']]) && is_string($t['formations'][$f['id']])) { + $c['formations'][$i]['diplome'] = $t['formations'][$f['id']]; + } + } + foreach ($c['certifications'] as $i => $f) { + if (isset($t['certifications'][$f['id']]) && is_string($t['certifications'][$f['id']])) { + $c['certifications'][$i]['nom'] = $t['certifications'][$f['id']]; + } + } + return $c; +} + +/** + * Titres traduits d'une liste de profils (Kanban, recherche, suivi) : [id => titre]. + * Pour une liste, la traduction est prise si elle existe et qu'aucune nouvelle traduction n'attend dans la file. + */ +function titres_traduits(array $ids, string $langue): array +{ + $ids = array_values(array_filter(array_map('intval', $ids))); + if (!$ids) { + return []; + } + $liste = implode(', ', $ids); + $req = db()->prepare("SELECT t.candidat_id, JSON_UNQUOTE(JSON_EXTRACT(t.contenu, '$.titre')) FROM traductions t + WHERE t.langue = ? AND t.candidat_id IN ($liste) + AND NOT EXISTS (SELECT 1 FROM traductions_a_faire f WHERE f.candidat_id = t.candidat_id AND f.langue = t.langue)"); + $req->execute([$langue]); + return array_filter($req->fetchAll(PDO::FETCH_KEY_PAIR), static fn ($v) => is_string($v) && $v !== '' && $v !== 'null'); +} + +/** Remplace le titre des lignes d'une liste par sa traduction dans la langue de l'interface. */ +function traduire_titres(array $lignes, string $cle = 'id'): array +{ + $titres = titres_traduits(array_column($lignes, $cle), langue_courante()); + foreach ($lignes as &$l) { + if (isset($titres[$l[$cle]])) { + $l['titre'] = $titres[$l[$cle]]; + } + } + unset($l); + return $lignes; +} diff --git a/stockage/docs/.gitkeep b/stockage/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/stockage/photos/.gitkeep b/stockage/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/stockage/tmp/.gitkeep b/stockage/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/.htaccess b/tests/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/tests/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/tests/test_aide.php b/tests/test_aide.php new file mode 100644 index 0000000..42b75e0 --- /dev/null +++ b/tests/test_aide.php @@ -0,0 +1,39 @@ + 'Junior']; +const STATUTS = ['recu' => 'Reçu']; +require __DIR__ . '/../src/i18n.php'; +require __DIR__ . '/../src/Recherche.php'; +require __DIR__ . '/../src/markdown.php'; +require __DIR__ . '/../src/aide.php'; + +$ok = 0; $ko = 0; +function verifier(string $nom, bool $cond): void { + global $ok, $ko; + if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; } +} + +foreach (array_keys(DOCUMENTS_AIDE) as $guide) { + $fr = aide($guide, 'fr'); + $en = aide($guide, 'en'); + verifier("$guide : existe en français et en anglais", $fr !== null && $en !== null && $fr['langue'] === 'fr' && $en['langue'] === 'en'); + verifier("$guide : même nombre de chapitres dans les deux langues", count($fr['chapitres']) === count($en['chapitres']) && count($fr['chapitres']) >= 10); + foreach (['fr' => $fr, 'en' => $en] as $langue => $a) { + preg_match_all('/ id="([^"]+)"/', $a['html'], $ids); + verifier("$guide ($langue) : ancres uniques et non vides", count($ids[1]) === count(array_unique($ids[1])) && !in_array('', $ids[1], true)); + verifier("$guide ($langue) : chaque chapitre du sommaire a son ancre", !array_diff(array_column($a['chapitres'], 'id'), $ids[1])); + verifier("$guide ($langue) : aucun balisage Markdown resté visible", !preg_match('/\*\*|^#|\|\s*---/m', strip_tags($a['html']))); + } + // Même structure : autant de sous-titres, de tableaux et de listes numérotées de part et d'autre + foreach ([' 'sous-titres', '' => 'tableaux', '
      ' => 'listes numérotées', '
    ' => 'lignes de tableau'] as $balise => $nom) { + verifier("$guide : autant de $nom en français qu'en anglais", substr_count($fr['html'], $balise) === substr_count($en['html'], $balise)); + } +} +verifier('langue sans fichier : le français est servi', aide('guide', 'de')['langue'] === 'fr'); +verifier('guide inconnu ou nom piégé : rien', aide('autre', 'fr') === null && aide('../config/config', 'fr') === null && aide('guide', '../x') === null); +verifier('ancre lisible, sans accent', aide('guide', 'fr')['chapitres'][1]['id'] === 'se-connecter'); + +echo "$ok réussis, $ko échoués\n"; +exit($ko > 0 ? 1 : 0); diff --git a/tests/test_i18n.php b/tests/test_i18n.php new file mode 100644 index 0000000..301895b --- /dev/null +++ b/tests/test_i18n.php @@ -0,0 +1,39 @@ + $traduit) { + preg_match_all('/%[sd]/', $fr, $a); preg_match_all('/%[sd]/', $traduit, $b); + if ($a[0] !== $b[0]) { $ecarts[] = $fr; } + } + verifier("$langue : variables identiques dans chaque traduction" . ($ecarts ? ' (' . implode(' | ', $ecarts) . ')' : ''), !$ecarts); +} + +echo "$ok réussis, $ko échoués\n"; +exit($ko > 0 ? 1 : 0); diff --git a/tests/test_import.php b/tests/test_import.php new file mode 100644 index 0000000..92a9485 --- /dev/null +++ b/tests/test_import.php @@ -0,0 +1,76 @@ + + ['poste' => 'x', 'type' => $t, 'date_debut' => $d, 'date_fin' => $f, 'technologies' => $tech]; + +verifier('poste terminé : mois de fin compté', Import::moisExperience([$exp('2020-01', '2020-12')], $auj) === 12); +verifier('chevauchement fusionné', Import::moisExperience([$exp('2020-01', '2020-12'), $exp('2020-07', '2021-06')], $auj) === 18); +verifier('périodes disjointes additionnées', Import::moisExperience([$exp('2018-01', '2018-12'), $exp('2020-01', '2020-12')], $auj) === 24); +verifier('stage exclu', Import::moisExperience([$exp('2020-01', '2020-06', 'stage')], $auj) === 0); +verifier('alternance comptée', Import::moisExperience([$exp('2020-01', '2020-06', 'alternance')], $auj) === 6); +verifier('poste en cours : jusqu\'au dernier mois complet', Import::moisExperience([$exp('2026-01', null)], $auj) === 9); +verifier('fin dans le futur plafonnée', Import::moisExperience([$exp('2026-01', '2030-12')], $auj) === 10); +verifier('année seule', Import::moisExperience([$exp('2015', '2016')], $auj) === 13); +verifier('filtre par technologie, casse ignorée', + Import::moisExperience([$exp('2020-01', '2020-12', 'emploi', ['Java']), $exp('2021-01', '2021-12', 'emploi', ['PHP'])], $auj, 'java') === 12); + +verifier('3,9 ans : junior', Import::niveau(3.9) === 'junior'); +verifier('4 ans : intermédiaire', Import::niveau(4.0) === 'intermediaire'); +verifier('7,9 ans : intermédiaire', Import::niveau(7.9) === 'intermediaire'); +verifier('8 ans : confirmé', Import::niveau(8.0) === 'confirme'); +verifier('12 ans : expert', Import::niveau(12.0) === 'expert'); + +echo "$ok réussis, $ko échoués\n"; +exit($ko > 0 ? 1 : 0); diff --git a/tests/test_photo.php b/tests/test_photo.php new file mode 100644 index 0000000..0fd8a2b --- /dev/null +++ b/tests/test_photo.php @@ -0,0 +1,92 @@ +open("$tmp/cv.docx", ZipArchive::CREATE); +$zip->addFromString('word/document.xml', ''); +$zip->addFromString('word/media/image1.png', logo(400, 400)); +$zip->addFromString('word/media/image2.jpeg', portrait(600, 760)); +$zip->addFromString('word/media/image3.jpeg', portrait(900, 120)); +$zip->addFromString('word/media/image4.png', portrait(40, 50, 'png')); +$zip->close(); +[$l, $h, $type] = dimensions(Photo::extraire("$tmp/cv.docx", 'docx')); +verifier('Word : le portrait est retenu, pas le logo ni le bandeau', $type === IMAGETYPE_JPEG && abs($l / max($h, 1) - 600 / 760) < 0.02); +verifier('Word : photo réduite à 480 px au plus', max($l, $h) === 480); + +$zip = new ZipArchive(); $zip->open("$tmp/sans.docx", ZipArchive::CREATE); +$zip->addFromString('word/document.xml', ''); +$zip->addFromString('word/media/image1.png', logo(400, 400)); +$zip->close(); +verifier('Word sans photo : rien n\'est retenu', Photo::extraire("$tmp/sans.docx", 'docx') === null); + +// --- PDF : image JPEG +$jpeg = portrait(300, 380); +$pdf = "%PDF-1.4\n1 0 obj\n<< /Type /XObject /Subtype /Image /Width 300 /Height 380 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " . strlen($jpeg) . " >>\nstream\n" . $jpeg . "\nendstream\nendobj\n%%EOF"; +file_put_contents("$tmp/jpeg.pdf", $pdf); +[$l, $h] = dimensions(Photo::extraire("$tmp/jpeg.pdf", 'pdf')); +verifier('PDF : image JPEG retrouvée', $l === 300 && $h === 380); + +// --- PDF : image compressée sans perte, avec et sans prédicteur +$img = imagecreatefromstring(portrait(200, 240)); +$brut = ''; $filtre = ''; +for ($y = 0; $y < 240; $y++) { + $ligne = ''; + for ($x = 0; $x < 200; $x++) { $c = imagecolorat($img, $x, $y); $ligne .= chr(($c >> 16) & 255) . chr(($c >> 8) & 255) . chr($c & 255); } + $brut .= $ligne; $filtre .= "\0" . $ligne; +} +$objet = static fn (string $donnees, string $plus = ''): string => "%PDF-1.4\n4 0 obj\n<< /Subtype /Image /Width 200 /Height 240 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode $plus/Length 9 0 R >>\nstream\r\n" . $donnees . "\r\nendstream\nendobj\n%%EOF"; +file_put_contents("$tmp/flate.pdf", $objet(gzcompress($brut))); +[$l, $h] = dimensions(Photo::extraire("$tmp/flate.pdf", 'pdf')); +verifier('PDF : image sans perte retrouvée, longueur indirecte', $l === 200 && $h === 240); +file_put_contents("$tmp/predicteur.pdf", $objet(gzcompress($filtre), '/DecodeParms << /Predictor 15 /Colors 3 /Columns 200 >> ')); +[$l, $h] = dimensions(Photo::extraire("$tmp/predicteur.pdf", 'pdf')); +verifier('PDF : image sans perte avec prédicteur retrouvée', $l === 200 && $h === 240); + +file_put_contents("$tmp/texte.pdf", "%PDF-1.4\n1 0 obj\n<< /Length 20 >>\nstream\nBT (Bonjour) Tj ET\nendstream\nendobj\n%%EOF"); +verifier('PDF sans image : rien n\'est retenu', Photo::extraire("$tmp/texte.pdf", 'pdf') === null); +file_put_contents("$tmp/casse.pdf", "%PDF-1.4 << /Subtype /Image /Width 300 /Height 300 /Filter /DCTDecode >> stream\nceci n'est pas une image"); +verifier('PDF endommagé : aucune erreur', Photo::extraire("$tmp/casse.pdf", 'pdf') === null); +verifier('format sans image (txt) : rien', Photo::extraire(__FILE__, 'txt') === null); + +// --- Normalisation +[$l, $h, $type] = dimensions(Photo::normaliser(portrait(1200, 800, 'png'))); +verifier('chargement manuel : PNG converti en JPEG réduit', $type === IMAGETYPE_JPEG && $l === 480 && $h === 320); +verifier('fichier qui n\'est pas une image : refusé', Photo::normaliser(' 0 ? 1 : 0); diff --git a/tests/test_recherche.php b/tests/test_recherche.php new file mode 100644 index 0000000..224d2ca --- /dev/null +++ b/tests/test_recherche.php @@ -0,0 +1,37 @@ + 'Junior', 'intermediaire' => 'Intermédiaire', 'confirme' => 'Confirmé', 'expert' => 'Expert']; +const STATUTS = ['recu' => 'Reçu']; +require __DIR__ . '/../src/Recherche.php'; +require __DIR__ . '/../src/markdown.php'; + +$ok = 0; $ko = 0; +function verifier(string $nom, bool $cond): void { + global $ok, $ko; + if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; } +} +$t = static fn (string $s, bool $d = true): array => Recherche::termes($s, $d); + +verifier('minuscules et accents', $t('Développeur Sénior') === ['developpeur', 'senior']); +verifier('c# conservé', in_array('c#', $t('C# et Java'), true)); +verifier('c++ conservé', in_array('c++', $t('Expert C++.'), true)); +verifier('.NET reconnu', in_array('dotnet', $t('ASP .NET Core'), true)); +verifier('node.js entier et décomposé', !array_diff(['node.js', 'node', 'js'], $t('Node.js'))); +verifier('ci/cd entier et décomposé', !array_diff(['ci/cd', 'ci', 'cd'], $t('CI/CD'))); +verifier('full-stack entier et décomposé', !array_diff(['full-stack', 'full', 'stack'], $t('Full-Stack'))); +verifier('requête non décomposée', $t('full-stack', false) === ['full-stack']); +verifier('apostrophe séparatrice', $t("d'applications") === ['d', 'applications']); +verifier('ponctuation finale retirée', $t('Java, Spring.') === ['java', 'spring']); +verifier('lettre seule gardée', in_array('r', $t('R et Python'), true)); +verifier('œ développé', $t('Cœur') === ['coeur']); + +$html = markdown_vers_html("# Titre\n\nTexte **gras** et \n\n- un\n- deux\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"); +verifier('titre décalé d\'un niveau', str_contains($html, '

    Titre

    ')); +verifier('gras', str_contains($html, 'gras')); +verifier('HTML du fichier neutralisé', !str_contains($html, ' + + diff --git a/vues/double_authentification.php b/vues/double_authentification.php new file mode 100644 index 0000000..dad4a97 --- /dev/null +++ b/vues/double_authentification.php @@ -0,0 +1,53 @@ + +
      +
    1. + + +
    2. +
    3. + + + + +
    4. +
    5. + +
      + + + + + + + + +
    6. +
    + + +

    /

    +
    +

    +

    + +
    + +
    + <?= e(t('IT Club')) ?> +

    +

    + +

    +
    + + diff --git a/vues/erreurs.php b/vues/erreurs.php new file mode 100644 index 0000000..3cf6ba0 --- /dev/null +++ b/vues/erreurs.php @@ -0,0 +1,31 @@ +
    +

    + +
    + + + + +
    +

    + +

    + +
    +
    + + + + + + + + + + + + + +

    + + diff --git a/vues/europass.php b/vues/europass.php new file mode 100644 index 0000000..e69de29 diff --git a/vues/gabarit.php b/vues/gabarit.php new file mode 100644 index 0000000..46b3594 --- /dev/null +++ b/vues/gabarit.php @@ -0,0 +1,140 @@ + in_array($page ?? '', $pages, true) ? ' aria-current="page"' : ''; +$relancesDues = $u ? nombre_relances_dues() : 0; +$pagesGestion = ['conservation', 'journal', 'connexions', 'utilisateurs', 'sauvegarde', 'corbeille', 'erreurs']; +// Le menu Gestion est réservé au gestionnaire : conservation, journal, connexions, comptes, corbeille, sauvegarde, erreurs +$admin = $u !== null && $u['role'] === 'admin'; +$echus = $admin ? nombre_echus() : 0; +$demandes = $admin ? nombre_demandes() : 0; +// Traductions à faire : le script les traite en arrière-plan, une par une +$aTraduire = $u && peut_ecrire() && Claude::configure() ? nombre_traductions_a_faire() : 0; +// Page publique (guide Sécurité lu sans être connecté) : fond clair, sans menu +$classeCorps = $u ? 'connecte' : (($page ?? '') === 'securite' ? 'public' : 'accueil'); +$aLaCorbeille = $admin ? nombre_corbeille() : 0; +// Menu du compte : initiales dans la pastille, prénom à côté +$mots = $u ? (preg_split('/\s+/u', trim($u['nom_affiche'])) ?: []) : []; +$initiales = mb_strtoupper(mb_substr($mots[0] ?? '', 0, 1) . (count($mots) > 1 ? mb_substr((string) end($mots), 0, 1) : '')); +$prenomCompte = $mots[0] ?? ''; +$ici = 'index.php?' . http_build_query(array_filter($_GET, 'is_string')); +// Textes dont le JavaScript a besoin, dans la langue de l'interface +$textesJs = [ + 'echecEnregistrement' => t('Le changement n\'a pas été enregistré.'), + 'connexionPerdue' => t('Connexion perdue. Le changement n\'a pas été enregistré.'), + 'analyseEnCours' => t('Analyse en cours…'), + 'lotEnAttente' => t('En attente'), + 'lotPret' => t('Prêt à relire'), + 'lotErreur' => t('Erreur'), + 'lotTermine' => t('Analyses terminées. La page se recharge.'), + 'fermer' => t('Fermer'), + 'filtres' => t('Filtres'), + 'copie' => t('Copié'), +]; +$langues = static function () use ($ici): string { + $html = '
    ' . champ_csrf() + . ''; + foreach (LANGUES_INTERFACE as $code => $nom) { + $html .= ''; + } + return $html . '
    '; +}; +?> + + + + + + + + + + + <?= e($titre ?? '') ?> · <?= e(config('nom_app')) ?> + + + + + + +
    +
    + + IT Club + + + +
    + +
    + +
    + + IT Club + + +
    + +
    + + +
    + +

    + + +
    + + + + + diff --git a/vues/import.php b/vues/import.php new file mode 100644 index 0000000..437a070 --- /dev/null +++ b/vues/import.php @@ -0,0 +1,48 @@ +
    +

    + +
    + + + + + +
    +
    +

    + +

    +
    + + + + +
    + +

    + +
    + +
    +

    +

    +
    + + + + + +
    +
    +
    diff --git a/vues/import_lot.php b/vues/import_lot.php new file mode 100644 index 0000000..32cea5f --- /dev/null +++ b/vues/import_lot.php @@ -0,0 +1,97 @@ + t('En attente'), 'pret' => t('Prêt à relire'), 'erreur' => t('Erreur')]; +$motifs = ['email' => t('même adresse email'), 'telephone' => t('même téléphone'), 'nom' => t('même prénom et même nom')]; +$attente = count(array_filter($lignes, static fn ($l) => $l['etat'] === 'en_attente')); +$prets = count(array_filter($lignes, static fn ($l) => $l['etat'] === 'pret')); +$places = LOT_MAX - count($lignes); +?> +

    /

    +
    +

    +
    +

    + + 0): ?> +
    +
    +

    + +

    +
    + + + +
    + +

    + +
    + +
    +

    +

    +
    + + + +
    +
    +
    + +

    + + + +
    +
    +

    + +
    + + +
    + +
    + +

    + +

    + +
    + + + + + + + + + + + +

    ,
    + +

    +
    + + +
    : $motifs[$m], $d['motifs']))) ?> + +
    +
    + + + + + + + + +
    +
    +
    + diff --git a/vues/import_relecture.php b/vues/import_relecture.php new file mode 100644 index 0000000..87dd159 --- /dev/null +++ b/vues/import_relecture.php @@ -0,0 +1,109 @@ +' . e(t('non renseigné')) . ''; +?> +
    +

    + +

    + + + t('même adresse email'), 'telephone' => t('même téléphone'), 'nom' => t('même prénom et même nom')]; ?> + + + +
    + +
    + +
    + +
    + <?= e(t('Photo trouvée dans le CV')) ?> + +
    + +
    +
    +
    +
    +
    +
    ' . e(t('non renseignée')) . '' ?>
    + +
    + $profil['disponibilite_code'], 'date' => $profil['disponibilite_date'] ?? null] : classer_disponibilite($profil['disponibilite'] ?? null); ?> +
    $dispoImport['code'], 'disponible_le' => $dispoImport['code'] === 'date' ? $dispoImport['date'] : date_disponibilite($dispoImport['code'], null)])) !== '' ? e($libelle) : $vide ?> ()
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +

    + +

    + + + +

    +
    + + +

    +
    + + + + + + + + + + + +
    →
    + +
    + + + + +
    +
    diff --git a/vues/inscription.php b/vues/inscription.php new file mode 100644 index 0000000..d7471ba --- /dev/null +++ b/vues/inscription.php @@ -0,0 +1,15 @@ +
    + <?= e(t('IT Club')) ?> +

    +

    +
    +

    +

    +

    +
    +

    + + + +

    +
    diff --git a/vues/installation.php b/vues/installation.php new file mode 100644 index 0000000..0b68a42 --- /dev/null +++ b/vues/installation.php @@ -0,0 +1,23 @@ +
    + <?= e(t('IT Club')) ?> +

    +

    +
    + + + + + + + +
    +

    +
    diff --git a/vues/introuvable.php b/vues/introuvable.php new file mode 100644 index 0000000..3d646fe --- /dev/null +++ b/vues/introuvable.php @@ -0,0 +1,4 @@ +
    +

    +

    +
    diff --git a/vues/invitation.php b/vues/invitation.php new file mode 100644 index 0000000..bbd3c78 --- /dev/null +++ b/vues/invitation.php @@ -0,0 +1,33 @@ +
    + <?= e(t('IT Club')) ?> +

    + +

    + +

    + +

    +
    + + + + + + + + +
    +

    +

    + +
    diff --git a/vues/journal.php b/vues/journal.php new file mode 100644 index 0000000..fadc495 --- /dev/null +++ b/vues/journal.php @@ -0,0 +1,39 @@ +

    + +
    + + + +
    + +
    + +

    + +
    + + + + + + + + + + + + +
    + + · + + + +
    + +
    diff --git a/vues/kanban.php b/vues/kanban.php new file mode 100644 index 0000000..3666148 --- /dev/null +++ b/vues/kanban.php @@ -0,0 +1,76 @@ + +
    +

    + +
    + +
    + $libelle): ?> +
    +

    +
      + +
    • +
      + +
      + +

      +
      +
      +

      + + +

      + +

      + + + +

      + + + +

      + + +

      + +
    • + +
    +
    + +
    + + +
    +

    + +
    + + +
    +
    +
    + diff --git a/vues/nouveau_mot_de_passe.php b/vues/nouveau_mot_de_passe.php new file mode 100644 index 0000000..e8461dd --- /dev/null +++ b/vues/nouveau_mot_de_passe.php @@ -0,0 +1,17 @@ +
    + <?= e(t('IT Club')) ?> +

    +

    +
    + + + + + +
    +

    +
    diff --git a/vues/profil.php b/vues/profil.php new file mode 100644 index 0000000..ae45c98 --- /dev/null +++ b/vues/profil.php @@ -0,0 +1,762 @@ + $n === null ? '' : nombre($n); +$mois = static fn (?string $d): string => $d ? (new DateTimeImmutable($d))->format('m/Y') : t('en cours'); +$adresse = implode(', ', array_filter([$c['rue'], trim(($c['code_postal'] ?? '') . ' ' . ($c['ville'] ?? '')), $c['pays']])); +$taille = static fn (int $o): string => $o >= 1048576 ? number_format($o / 1048576, 1, ',', '') . ' Mo' : max(1, (int) round($o / 1024)) . ' Ko'; +$categories = array_map('t', [ + 'langage' => 'Langage', 'framework' => 'Framework', 'base_de_donnees' => 'Base de données', 'outil' => 'Outil', + 'cloud' => 'Cloud', 'methode' => 'Méthode', 'autre' => 'Autre', +]); +$archive = $c['archive_le'] !== null; +$dateHeure = static fn (string $d): string => date_fr($d, !str_ends_with($d, '00:00:00')); +$aujourdhui = date('Y-m-d'); +$nonRenseigne = static fn (string $texte = 'non renseigné'): string => '' . e(t($texte)) . ''; + +// Ce qui demande une action : alimente la ligne « À faire » du bandeau et les pastilles des onglets +$relancesOuvertes = array_values(array_filter($c['relances'], static fn ($r) => $r['fait_le'] === null)); +$relancesEchues = array_filter($relancesOuvertes, static fn ($r) => $r['echeance'] <= $aujourdhui); +$entretiensAVenir = array_filter($c['entretiens'], static fn ($x) => substr($x['date_entretien'], 0, 10) >= $aujourdhui); +$prochainEntretien = $entretiensAVenir ? min(array_column($entretiensAVenir, 'date_entretien')) : null; +$avisADonner = array_filter($c['entretiens'], static fn ($x) => substr($x['date_entretien'], 0, 10) < $aujourdhui && !$x['avis']); +$conservationDepassee = $c['conserver_jusqu_au'] !== null && $c['conserver_jusqu_au'] < $aujourdhui; +$aSuivre = count($relancesEchues) + count($avisADonner); + +// Disponibilité : la valeur choisie dans la liste ; à défaut, le texte du CV +$disponibilite = libelle_disponibilite($c) ?: (string) $c['disponibilite']; + +// Permis de travail : non requis avec une nationalité UE, EEE ou suisse ; sinon le texte saisi et la date de fin du titre +$nationalites = implode(', ', array_map(static fn ($code) => nom_pays($code), $c['nationalites_codes'])); +$permisLibre = $c['hors_ue'] !== null && (int) $c['hors_ue'] === 0; +$titreExpire = !$permisLibre && $c['permis_expire_le'] !== null && $c['permis_expire_le'] < $aujourdhui; +$titreBientot = !$permisLibre && $c['permis_expire_le'] !== null && !$titreExpire && $c['permis_expire_le'] <= date('Y-m-d', strtotime('+2 months')); + +// Traduction du contenu dans la langue de l'interface +$tr = $c['traduction']; +$langueNom = ['fr' => t('en français'), 'en' => t('en anglais')][langue_courante()] ?? langue_courante(); + +// Repli : les plus récents d'abord, le reste se déplie +$nbExperiences = 3; +$nbTechnologies = 10; +$experiencesVisibles = array_slice($c['experiences'], 0, $nbExperiences); +$experiencesAnciennes = array_slice($c['experiences'], $nbExperiences); +$technologiesVisibles = array_slice($c['technologies'], 0, $nbTechnologies); +$technologiesAutres = array_slice($c['technologies'], $nbTechnologies); + +$onglets = [ + 'profil' => ['libelle' => t('Profil'), 'pastille' => 0, 'titre' => ''], + 'suivi' => ['libelle' => t('Suivi'), 'pastille' => $aSuivre, 'titre' => t('Relances en retard et avis à donner')], + 'missions' => ['libelle' => t('Missions'), 'pastille' => 0, 'titre' => ''], + 'dossier' => ['libelle' => t('Dossier'), 'pastille' => $conservationDepassee ? 1 : 0, 'titre' => t('Conservation dépassée')], +]; +$compteurs = ['suivi' => count($c['entretiens']) + count($c['commentaires']), 'missions' => count($c['besoins']), 'dossier' => count($c['documents'])]; + +/** Une expérience professionnelle. */ +$experience = static function (array $x) use ($mois): void { ?> +
    +

    → ·

    +

    +

    + +

    + +

    + +

    +

    + +

    + +
    + + + $x['besoin_id'], 'client' => $x['besoin_client'], 'intitule' => $x['besoin_intitule']]; + } + ?> +
    + + + + + + + + + +
    + +

    + + / + + / + +

    + + +
    +
    + + +
    +

    +

    + + + +

    +
    +
    +
    + + + + + + + + +
    +
    + + +
    +

    + +
    + + + +
    + +
    + + + +

    + +
    +

    +

    + +
    + + + + +
    + +
    + +

    +

    + + +
    +
    + +
    + + + + + +
    + +

    + +
    +
    +
    + +
    + +
    +
    + +

    :

    + + +
      + +
    • + + +
    • + + +
    • + + +
    • + + +
    • + +
    + +
    + + + + +
    +

    +
    +
    + +
    +

    +

    +
    + + + +
    +

    +
    +
    + + +
    +

    + + +
    + + +
    + +

    +
    + +
    +

    + + + + +
    + +
    + +
    +
    + +

    +
    + + +
    +

    +
      + + +
    • ' . e(implode(' · ', array_filter([$dates, $f['lieu'] ?? null]))) . '' : '' ?>
    • + + +
    • + +
    +
    + +
    + + +
    +
    + + +
    +

    +
    +
    +
    +

    + = $aujourdhui; ?> +
    +

    + · + + +

    +

    +

    + +
    + +
    + + + +
    + +
    +
    +
    + + + +
    +
    + +
    + +

    + +
    + +
    + + + + + + +
    + +
    +
    +
    + +
    + +
    +

    + +
    + + + +
    +
    + + +
    +

    ·

    +

    +
    + +

    +
    +
    + + +
    +
    + + +
    +

    +
    +
    +

    +
      + +
    • + + +
    • + +
    +

    + !in_array((int) $b['id'], array_map('intval', array_column($c['besoins'], 'id')), true)); ?> + +
    + + + + +
    +
    + +
    + +
    +

    +
    +
    +
    +
    +
    +
    +
    +
    +

    +

    +
    +
    +
    + + +
    +

    +
    +
    +
    +

    +
      + +
    • + + · · , + + + + + +
      + + + +
      + +
      +
    • + +
    +

    + +
    + +
    + + + + +
    +
    +
    + +
    + +
    +

    + +
    +
    +
    +
    + + $libelle): if (!$c['mots_cles'][$type]) { continue; } ?> +

    +
      + + > + + +
      + + + + +
      + + + +
    + +

    + +
    + + + + + +
    + +
    + +
    +

    + +
  • + · + +
  • + +
    + +
    + +
    +
    + +
    +
    + + +
    +
    diff --git a/vues/profil_modifier.php b/vues/profil_modifier.php new file mode 100644 index 0000000..702f382 --- /dev/null +++ b/vues/profil_modifier.php @@ -0,0 +1,129 @@ + +

    /

    +
    +

    +

    + +
    + + + + > + > + + + + +
    + +

    +
    + + + + +
    +
    + +
    + +

    +
    + + + + + + +
    +
    + +
    + +

    +
    + + + +
    +

    >

    +
    > + + +
    +
    + +
    + +
    + + + +
    +
    + +
    + +

    +
    + + + + +
    +
    + +
    + +

    +
    + + +
    +
    + +
    + + +
    + +
    diff --git a/vues/rapport.php b/vues/rapport.php new file mode 100644 index 0000000..375cb7e --- /dev/null +++ b/vues/rapport.php @@ -0,0 +1,8 @@ +

    + / + +

    +
    + +
    +

    diff --git a/vues/recherche.php b/vues/recherche.php new file mode 100644 index 0000000..468b140 --- /dev/null +++ b/vues/recherche.php @@ -0,0 +1,100 @@ + nombre($n); +$n = count($resultats); +$filtre = $q !== '' || array_filter($filtres); +?> +

    + + + + +

    t(SENIORITES[$s] ?? $s), $analyse['seniorites'])))) ?>

    + + + +
    +

    +

    +
    + +
      + +
    • + +
      +
      + + +
      +

      + + + + + + +

      + +

      + + > + + + + +

      + +
      +
    • + +
    + diff --git a/vues/sauvegarde.php b/vues/sauvegarde.php new file mode 100644 index 0000000..61e5b58 --- /dev/null +++ b/vues/sauvegarde.php @@ -0,0 +1,110 @@ + t('Envoyée par email'), + 'sans_piece' => t('Email sans pièce jointe'), + 'echec' => t('Email non parti'), + 'non_demande' => t('Sans email'), +]; +$derniere = $etat['derniere']; +$configuree = $reglages['destinataires'] && $reglages['expediteur'] !== ''; +$copies = count(array_filter($liste, static fn ($s) => $s['fichier'] !== null)); +?> +

    + + + + + + + +
    +
    +

    +
    +
    +
    ' . e(t('aucune')) . '' ?>
    +
    +
    ' . e(t('depuis %s', $reglages['expediteur'])) . '' : '' . e(t('adresses absentes de config/config.php')) . '' ?>
    +
    +
    0 ? tn($reglages['semaines_conservees'], '%d semaine', '%d semaines') : t('sans limite de durée')) ?> ·
    +
    +

    +
    + + + +
    + +
    +
    + +
    + + +
    + +
    + +
    +

    +
    +
    +
    · ·
    +
    +

    +
    + + +
    +
    +
    +
    + +
    +

    + +

    + +
    + + + + + + + + + + + + + +
    + + + + + +
    +
    + + +
    + + + +
    + + + +
    + +
    diff --git a/vues/suivi.php b/vues/suivi.php new file mode 100644 index 0000000..686dabc --- /dev/null +++ b/vues/suivi.php @@ -0,0 +1,86 @@ + date_fr($d, !str_ends_with($d, '00:00:00')); +$dues = count(array_filter($relances, static fn ($r) => $r['jours'] <= 0)); +$delai = static function (int $j): string { + return match (true) { + $j < 0 => t('en retard de %d j', -$j), + $j === 0 => t('aujourd\'hui'), + default => t('dans %d j', $j), + }; +}; +?> +

    ·

    + +
    +
    +

    + +

    + +
      + +
    • + $r['candidat_id'], 'prenom' => $r['prenom'], 'nom' => $r['nom'], 'photo' => $r['photo']]) ?> +
      + + , +

      + · + +
      + + + + +
      + +
      +
    • + +
    + +
    + +
    +
    +

    + +

    + +
      + +
    • + $x['candidat_id'], 'prenom' => $x['prenom'], 'nom' => $x['nom'], 'photo' => $x['photo']]) ?> +
      + + +

      +
      +
    • + +
    + +
    + + +
    +

    +
      + +
    • + $x['candidat_id'], 'prenom' => $x['prenom'], 'nom' => $x['nom'], 'photo' => $x['photo']]) ?> +
      + + · +
      +
    • + +
    +
    + +
    +
    diff --git a/vues/tableau.php b/vues/tableau.php new file mode 100644 index 0000000..bc4c64c --- /dev/null +++ b/vues/tableau.php @@ -0,0 +1,109 @@ + nombre($n); +$max = static fn (array $valeurs): int => max(1, ...array_values(array_map('intval', $valeurs))); +/** Barre horizontale : la longueur est donnée par une classe de 0 à 20, la valeur est toujours écrite. */ +$barre = static fn (float $v, float $m): string => ''; +?> +

    + + + + + + +
    +

    +

    +

    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    + + + $libelle): ?> + + + + + + + +
    + +

    ,

    + +
    + +
    +

    + +

    + + + + $d): ?> + + + + + + + +
    +

    + +
    + +
    +

    + + + $libelle): ?> + + + + + + + +
    +
    + +
    +

    +

    + + + + + + + + + + +
    + +
    +
    + diff --git a/vues/utilisateurs.php b/vues/utilisateurs.php new file mode 100644 index 0000000..5eaef06 --- /dev/null +++ b/vues/utilisateurs.php @@ -0,0 +1,242 @@ + + + +
    +

    + +
    +

    + +
    +

    + +
    +

    +

    +
    + + +
    +
    + +
    + + + + +
    +

    + +

    +
      + +
    • + +
      + + + + +
      +
    • + +
    + +
    + + +
    +

    +

    +
      + +
    • +
      +

      +

      + : + +

      +
      +
      +
      + + + +
      +
      + + + +
      +
      +
    • + +
    +
    + + + +
    +

    +

    +
    + + + + + + + + + + + + + +
    +
    + + + + +
    +
    +
    +
    + + +
    +
    +

    +

    +
    + + + + + + +
    +
    + +
      + +
    • +
      +
      +

      ' . e(t('vous')) . '' : '' ?>

      +

      + : + + + ' . e(t('Double authentification à activer')) . '' ?> + + : +

      +
      +
      +
      + +
      + + + +
      + + + + +
      +
      +
      +
      + + +
      + +

      +
      + + + + +
      +
      +
      + +
      + +

      +
      + + + + +
      +
      +
      +
      +
      > + + + + +
      + +
      + +
    • + +
    +
    + +
    +

    +
    +
    +

    +

    +

    + +

    +
      + +
    • ·
    • + +
    + + +
    + + + +
    + +
    +
    +

    +

    +
    + + + +
    +
    +
    +
    diff --git a/vues/verification.php b/vues/verification.php new file mode 100644 index 0000000..63c3b09 --- /dev/null +++ b/vues/verification.php @@ -0,0 +1,20 @@ +
    + <?= e(t('IT Club')) ?> +

    +

    +
    + + + + +
    +
    + +

    +

    +
    +

    +