Import initial de Vivier depuis l'hebergement OVH
No files matched your search
@@ -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
|
||||
._*
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -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-<nom>-<prenom>.json` : les données structurées, conformes à `cv-profil.schema.json` version 1.2.
|
||||
2. `rapport-<nom>-<prenom>.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
|
||||
|
||||
```
|
||||
# <Prénom Nom> — <titre>
|
||||
|
||||
## 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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
// Copiez ce fichier en config.php et renseignez les valeurs.
|
||||
// config.php ne doit jamais être publié ni versionné.
|
||||
|
||||
return [
|
||||
'nom_app' => '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,
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Sauvegarde hebdomadaire de la base, envoyée par email, puis vidage de la corbeille des profils.
|
||||
// À déclarer dans l'espace client OVH, onglet « Plus », « Planificateur de tâches » :
|
||||
// commande : vivier/cron/sauvegarde.php langage : PHP 8.3 fréquence : chaque dimanche à 6 h
|
||||
// Ce dossier est hors de la racine web : le script ne peut pas être appelé par une adresse.
|
||||
|
||||
require __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
$resultat = Sauvegarde::executer('planifiee', true);
|
||||
|
||||
// Ce texte arrive dans le rapport d'exécution d'OVH
|
||||
echo date('Y-m-d H:i') . ' Vivier, sauvegarde : '
|
||||
. ($resultat['reussie']
|
||||
? 'faite, ' . $resultat['fichier'] . ' (' . Sauvegarde::taille((int) $resultat['taille']) . '), email : ' . $resultat['envoi']
|
||||
: 'ECHEC, ' . $resultat['detail'])
|
||||
. ($resultat['reussie'] && $resultat['detail'] !== '' ? ' ' . $resultat['detail'] : '') . "\n";
|
||||
|
||||
// La corbeille des profils se vide au même moment : les profils supprimés depuis plus de 30 jours sont effacés
|
||||
try {
|
||||
$effaces = purger_corbeille();
|
||||
echo date('Y-m-d H:i') . ' Vivier, corbeille : ' . $effaces . " profil(s) efface(s)\n";
|
||||
} catch (Throwable $ex) {
|
||||
echo date('Y-m-d H:i') . ' Vivier, corbeille : ECHEC, ' . $ex->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);
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
// Vivier — Deutsch. Nur die Beschriftungen der erzeugten Lebensläufe : l'interface n'est pas traduite en allemand.
|
||||
// Clé : le texte français. Valeur : sa traduction. Garder les %s.
|
||||
|
||||
return [
|
||||
'Séniorité' => '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:',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
|
||||
# HTTPS obligatoire : toute adresse en http:// est redirigée vers https://
|
||||
<IfModule mod_rewrite.c>
|
||||
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]
|
||||
</IfModule>
|
||||
<IfModule mod_headers.c>
|
||||
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
|
||||
</IfModule>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),n=t(/iP(ad|od|hone)/i),c=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function f(t,e,n){t.removeEventListener(e,n,!y&&d)}function p(t,e){if(e&&(">"===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<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=v(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(t,!0),i=X(t)[e];oLine truncated
|
||||
@@ -0,0 +1,816 @@
|
||||
/* Vivier — interface.
|
||||
Identité IT Club : noir et or du logo. Les barres empilées du logo deviennent l'échelle d'avancement des profils.
|
||||
Titres en Bodoni Moda, texte en Instrument Sans, les deux hébergées ici (licence SIL OFL). */
|
||||
|
||||
@font-face {
|
||||
font-family: "Instrument Sans"; font-style: normal; font-weight: 400 700; font-display: swap;
|
||||
src: url("polices/instrument-sans-latin-wght-normal.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bodoni Moda"; font-style: normal; font-weight: 400 900; font-display: swap;
|
||||
src: url("polices/bodoni-moda-latin-wght-normal.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bodoni Moda"; font-style: italic; font-weight: 400 900; font-display: swap;
|
||||
src: url("polices/bodoni-moda-latin-wght-italic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
--noir: #000000;
|
||||
--encre: #1c1c1a;
|
||||
--gris: #5a6066;
|
||||
--trait: #d3d8dc;
|
||||
--fond: #eceff1;
|
||||
--surface: #ffffff;
|
||||
--or: #ceb73b;
|
||||
--or-fonce: #74640c; /* texte doré lisible sur blanc */
|
||||
--or-pale: #f7f2d4;
|
||||
--petrole: #0d4a5d; /* liens et repère de focus */
|
||||
--brique: #a3341f;
|
||||
--brique-pale: #fbe9e4;
|
||||
--texte: "Instrument Sans", "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
|
||||
--titre: "Bodoni Moda", "Didot", "Bodoni MT", Georgia, serif;
|
||||
--gouttiere: clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { font-size: 100%; -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--fond);
|
||||
color: var(--encre);
|
||||
font: 400 0.9375rem/1.5 var(--texte);
|
||||
}
|
||||
|
||||
a { color: var(--petrole); text-underline-offset: 0.15em; text-decoration-thickness: 1px; }
|
||||
/* Survol : :where() ôte tout poids à la règle. Un lien coloré par une classe (logo, menu, bouton, lien sur fond noir)
|
||||
garde sa couleur au survol. Sans cela, « Vivier » passait en noir sur l'en-tête noir. */
|
||||
:where(a:hover) { color: var(--noir); }
|
||||
|
||||
h1 { font: 500 clamp(1.6rem, 1.2rem + 1.6vw, 2.25rem)/1.1 var(--titre); margin: 0; letter-spacing: -0.005em; }
|
||||
main > 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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
-- ---------------------------------------------------------------
|
||||
@@ -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');
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Analyse automatique d'un CV par l'API Claude (Messages API d'Anthropic).
|
||||
*
|
||||
* Les consignes, le schéma et l'exemple envoyés sont ceux du dossier claude/ : la même source
|
||||
* que pour l'analyse manuelle dans un projet Claude.
|
||||
*/
|
||||
final class Claude
|
||||
{
|
||||
public static function configure(): bool
|
||||
{
|
||||
return trim((string) (config('claude')['cle_api'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $fichier caractéristiques renvoyées par controler_fichier()
|
||||
* @return array{profil: mixed, rapport: string, modele: string}
|
||||
* @throws RuntimeException message lisible par l'utilisateur
|
||||
*/
|
||||
public static function analyser(array $fichier): array
|
||||
{
|
||||
$c = (array) config('claude');
|
||||
$dossier = VIVIER_RACINE . '/claude/';
|
||||
$aujourdhui = date('Y-m-d');
|
||||
|
||||
$systeme = (string) file_get_contents($dossier . 'consignes-projet-claude.md')
|
||||
. "\n\n## Schéma JSON à respecter\n\n" . file_get_contents($dossier . 'cv-profil.schema.json')
|
||||
. "\n\n## Exemple de profil conforme\n\n" . file_get_contents($dossier . 'exemple-profil.json')
|
||||
. "\n\n## Format de ta réponse\n\n"
|
||||
. "Tu réponds à un programme, pas à une personne. Ne crée aucun fichier. Écris exactement deux blocs, sans rien avant, entre ni après :\n\n"
|
||||
. "<profil_json>\nle profil JSON, sans balise de code\n</profil_json>\n<rapport_md>\nle rapport en Markdown\n</rapport_md>\n\n"
|
||||
. "Si le document n'est pas un CV ou est illisible, écris à la place : <erreur>la raison en une phrase</erreur>";
|
||||
|
||||
$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<cv>\n" . $texte . "\n</cv>"]];
|
||||
|
||||
$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('#<erreur>(.*?)</erreur>#su', $sortie, $m)) {
|
||||
throw new RuntimeException(t('Analyse impossible : %s', trim($m[1])));
|
||||
}
|
||||
if (!preg_match('#<profil_json>(.*?)</profil_json>#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('#<rapport_md>(.*?)</rapport_md>#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 : <traduction_json>l'objet JSON, sans balise de code</traduction_json>";
|
||||
|
||||
$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('#<traduction_json>(.*?)</traduction_json>#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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Export Word (.docx) du CV au format Europass.
|
||||
* Même principe que ExportWord : le fichier OOXML est écrit à la main avec ZipArchive.
|
||||
* Reçoit le contenu préparé par donnees_europass(), comme l'aperçu HTML.
|
||||
*
|
||||
* Police : Arial dans Word (Open Sans n'est pas installée partout) ; l'aperçu et le PDF utilisent Open Sans.
|
||||
* Unités : twips (1 mm = 56,7), demi-points pour les corps, huitièmes de point pour les filets.
|
||||
*/
|
||||
final class ExportEuropass
|
||||
{
|
||||
private const BLEU = '004B80';
|
||||
private const BLEU_CLAIR = '4D9ACB';
|
||||
private const FILET = 'C0DBED';
|
||||
private const FOND_LIGNE = 'F2F8FC';
|
||||
private const TEXTE = '1A1A1A';
|
||||
private const GRIS = '404040';
|
||||
private const GRIS_CLAIR = '6F6F6F';
|
||||
|
||||
private const PAGE_L = 11906;
|
||||
private const PAGE_H = 16838;
|
||||
private const MARGE = 794; // 14 mm
|
||||
private const TEXTE_L = self::PAGE_L - 2 * self::MARGE;
|
||||
|
||||
private const NS = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
|
||||
. 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
. 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" '
|
||||
. 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
. 'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"';
|
||||
|
||||
/**
|
||||
* @param array $d contenu renvoyé par donnees_europass()
|
||||
* @param string $logo chemin du logo Europass blanc (PNG transparent)
|
||||
* @param ?string $photo chemin de la photo JPEG, ou null
|
||||
*/
|
||||
public static function generer(array $d, string $logo, ?string $photo = null): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'europass');
|
||||
$zip = new ZipArchive();
|
||||
if ($tmp === false || $zip->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', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:hdr ' . self::NS . '>' . self::p('') . '</w:hdr>');
|
||||
$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 .= '<w:b/>'; }
|
||||
if (!empty($o['i'])) { $p .= '<w:i/>'; }
|
||||
if (!empty($o['caps'])) { $p .= '<w:caps/>'; }
|
||||
if (isset($o['color'])) { $p .= '<w:color w:val="' . $o['color'] . '"/>'; }
|
||||
if (isset($o['sz'])) { $p .= '<w:sz w:val="' . $o['sz'] . '"/><w:szCs w:val="' . $o['sz'] . '"/>'; }
|
||||
return '<w:r>' . ($p !== '' ? '<w:rPr>' . $p . '</w:rPr>' : '') . '<w:t xml:space="preserve">' . self::x($texte) . '</w:t></w:r>';
|
||||
}
|
||||
|
||||
/** 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 .= '<w:pStyle w:val="' . $o['style'] . '"/>'; }
|
||||
if (!empty($o['keepNext'])) { $p .= '<w:keepNext/>'; }
|
||||
if (isset($o['bordureBas'])) {
|
||||
$p .= '<w:pBdr><w:bottom w:val="single" w:sz="' . $o['bordureBas'][0] . '" w:space="4" w:color="' . $o['bordureBas'][1] . '"/></w:pBdr>';
|
||||
}
|
||||
if (isset($o['avant']) || isset($o['apres'])) {
|
||||
$p .= '<w:spacing' . (isset($o['avant']) ? ' w:before="' . $o['avant'] . '"' : '') . (isset($o['apres']) ? ' w:after="' . $o['apres'] . '"' : '') . '/>';
|
||||
}
|
||||
if (isset($o['retrait'])) {
|
||||
$p .= '<w:ind w:left="' . $o['retrait'] . '"' . (isset($o['suspendu']) ? ' w:hanging="' . $o['suspendu'] . '"' : '') . '/>';
|
||||
}
|
||||
if (isset($o['jc'])) { $p .= '<w:jc w:val="' . $o['jc'] . '"/>'; }
|
||||
return '<w:p>' . ($p !== '' ? '<w:pPr>' . $p . '</w:pPr>' : '') . $runs . '</w:p>';
|
||||
}
|
||||
|
||||
private static function tc(int $largeur, string $contenu, array $o = []): string
|
||||
{
|
||||
$p = '<w:tcW w:w="' . $largeur . '" w:type="dxa"/>';
|
||||
if (isset($o['span'])) { $p .= '<w:gridSpan w:val="' . $o['span'] . '"/>'; }
|
||||
if (isset($o['bordures'])) { $p .= '<w:tcBorders>' . $o['bordures'] . '</w:tcBorders>'; }
|
||||
if (isset($o['fond'])) { $p .= '<w:shd w:val="clear" w:color="auto" w:fill="' . $o['fond'] . '"/>'; }
|
||||
if (isset($o['marges'])) {
|
||||
[$h, $dr, $b, $g] = $o['marges'];
|
||||
$p .= '<w:tcMar><w:top w:w="' . $h . '" w:type="dxa"/><w:left w:w="' . $g . '" w:type="dxa"/>'
|
||||
. '<w:bottom w:w="' . $b . '" w:type="dxa"/><w:right w:w="' . $dr . '" w:type="dxa"/></w:tcMar>';
|
||||
}
|
||||
$p .= '<w:vAlign w:val="' . ($o['vAlign'] ?? 'top') . '"/>';
|
||||
return '<w:tc><w:tcPr>' . $p . '</w:tcPr>' . $contenu . '</w:tc>';
|
||||
}
|
||||
|
||||
private static function tbl(array $colonnes, string $lignes, int $retrait = 0): string
|
||||
{
|
||||
$grille = '';
|
||||
foreach ($colonnes as $l) {
|
||||
$grille .= '<w:gridCol w:w="' . $l . '"/>';
|
||||
}
|
||||
return '<w:tbl><w:tblPr><w:tblW w:w="' . array_sum($colonnes) . '" w:type="dxa"/>'
|
||||
. '<w:tblInd w:w="' . $retrait . '" w:type="dxa"/><w:tblLayout w:type="fixed"/>'
|
||||
. '<w:tblCellMar><w:left w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tblCellMar></w:tblPr>'
|
||||
. '<w:tblGrid>' . $grille . '</w:tblGrid>' . $lignes . '</w:tbl>';
|
||||
}
|
||||
|
||||
private static function filet(string $cote, int $taille, string $couleur): string
|
||||
{
|
||||
return '<w:' . $cote . ' w:val="single" w:sz="' . $taille . '" w:space="0" w:color="' . $couleur . '"/>';
|
||||
}
|
||||
|
||||
/** 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 '<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">'
|
||||
. '<wp:extent cx="' . $cx . '" cy="' . $cy . '"/><wp:docPr id="' . $id . '" name="' . $nom . '" descr="' . $nom . '"/>'
|
||||
. '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic>'
|
||||
. '<pic:nvPicPr><pic:cNvPr id="' . $id . '" name="' . $nom . '"/><pic:cNvPicPr/></pic:nvPicPr>'
|
||||
. '<pic:blipFill><a:blip r:embed="' . $rid . '"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
|
||||
. '<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="' . $cx . '" cy="' . $cy . '"/></a:xfrm>'
|
||||
. '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>'
|
||||
. '</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>';
|
||||
}
|
||||
|
||||
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('•') . '<w:r><w:tab/></w: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 = '<w:sectPr>'
|
||||
. '<w:headerReference w:type="first" r:id="rId4"/><w:headerReference w:type="default" r:id="rId5"/>'
|
||||
. '<w:footerReference w:type="default" r:id="rId3"/><w:footerReference w:type="first" r:id="rId3"/>'
|
||||
. '<w:pgSz w:w="' . self::PAGE_L . '" w:h="' . self::PAGE_H . '"/>'
|
||||
. '<w:pgMar w:top="850" w:right="' . self::MARGE . '" w:bottom="850" w:left="' . self::MARGE . '" w:header="0" w:footer="340" w:gutter="0"/>'
|
||||
. '<w:titlePg/>'
|
||||
. '</w:sectPr>';
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:document ' . self::NS . '><w:body>' . $corps . $section . '</w:body></w:document>';
|
||||
}
|
||||
|
||||
/** 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 = '<w:tr><w:trPr><w:trHeight w:val="340" w:hRule="exact"/></w:trPr>' . self::tc(self::PAGE_L, $vide, $bleu + ['span' => count($colonnes)]) . '</w:tr>'
|
||||
. '<w:tr>' . $cellules . '</w:tr>'
|
||||
. '<w:tr><w:trPr><w:trHeight w:val="300" w:hRule="exact"/></w:trPr>' . self::tc(self::PAGE_L, $vide, $bleu + ['span' => count($colonnes)]) . '</w:tr>';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:hdr ' . self::NS . '>'
|
||||
. self::tbl($colonnes, $lignes, -self::MARGE) . self::p('', ['apres' => 0]) . '</w:hdr>';
|
||||
}
|
||||
|
||||
/** 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 = '<w:tr>' . 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])
|
||||
. '</w:tr>';
|
||||
$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 .= '<w:tr>' . $sous . self::tc($lCase, self::p(''), $marges) . '</w:tr>';
|
||||
|
||||
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 .= '<w:tr><w:trPr><w:cantSplit/></w:trPr>' . $cellules . '</w:tr>';
|
||||
}
|
||||
return self::tbl($colonnes, $lignes);
|
||||
}
|
||||
|
||||
private static function pied(): string
|
||||
{
|
||||
$petit = ['color' => self::GRIS_CLAIR, 'sz' => 14];
|
||||
$champ = static fn (string $nom): string => '<w:fldSimple w:instr=" ' . $nom . ' ">' . self::r('1', $petit) . '</w:fldSimple>';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:ftr ' . self::NS . '>'
|
||||
. self::p($champ('PAGE') . self::r(' / ', $petit) . $champ('NUMPAGES'), ['jc' => 'right', 'apres' => 0])
|
||||
. '</w:ftr>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fichiers fixes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static function styles(string $langue): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
. '<w:docDefaults>'
|
||||
. '<w:rPrDefault><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial" w:eastAsia="Arial"/>'
|
||||
. '<w:color w:val="' . self::TEXTE . '"/><w:sz w:val="19"/><w:szCs w:val="19"/><w:lang w:val="' . $langue . '"/></w:rPr></w:rPrDefault>'
|
||||
. '<w:pPrDefault><w:pPr><w:spacing w:before="0" w:after="40" w:line="264" w:lineRule="auto"/></w:pPr></w:pPrDefault>'
|
||||
. '</w:docDefaults>'
|
||||
. '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
|
||||
. '<w:style w:type="paragraph" w:styleId="TitreEuropass"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>'
|
||||
. '<w:pPr><w:keepNext/><w:pBdr><w:bottom w:val="single" w:sz="12" w:space="2" w:color="' . self::BLEU_CLAIR . '"/></w:pBdr>'
|
||||
. '<w:spacing w:before="200" w:after="120"/><w:ind w:left="-280"/><w:outlineLvl w:val="1"/></w:pPr>'
|
||||
. '<w:rPr><w:b/><w:caps/><w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr></w:style>'
|
||||
. '</w:styles>';
|
||||
}
|
||||
|
||||
private static function reglages(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
. '<w:defaultTabStop w:val="708"/>'
|
||||
. '<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat>'
|
||||
. '</w:settings>';
|
||||
}
|
||||
|
||||
private static function types(): string
|
||||
{
|
||||
$wml = 'application/vnd.openxmlformats-officedocument.wordprocessingml.';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Default Extension="png" ContentType="image/png"/>'
|
||||
. '<Default Extension="jpg" ContentType="image/jpeg"/>'
|
||||
. '<Override PartName="/word/document.xml" ContentType="' . $wml . 'document.main+xml"/>'
|
||||
. '<Override PartName="/word/styles.xml" ContentType="' . $wml . 'styles+xml"/>'
|
||||
. '<Override PartName="/word/settings.xml" ContentType="' . $wml . 'settings+xml"/>'
|
||||
. '<Override PartName="/word/footer1.xml" ContentType="' . $wml . 'footer+xml"/>'
|
||||
. '<Override PartName="/word/header1.xml" ContentType="' . $wml . 'header+xml"/>'
|
||||
. '<Override PartName="/word/header2.xml" ContentType="' . $wml . 'header+xml"/>'
|
||||
. '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||
. '</Types>';
|
||||
}
|
||||
|
||||
private static function relsRacine(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private static function relsDocument(): string
|
||||
{
|
||||
$t = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="' . $t . 'styles" Target="styles.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="' . $t . 'settings" Target="settings.xml"/>'
|
||||
. '<Relationship Id="rId3" Type="' . $t . 'footer" Target="footer1.xml"/>'
|
||||
. '<Relationship Id="rId4" Type="' . $t . 'header" Target="header1.xml"/>'
|
||||
. '<Relationship Id="rId5" Type="' . $t . 'header" Target="header2.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private static function relsEntete(bool $photo): string
|
||||
{
|
||||
$t = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="' . $t . 'image" Target="media/logo.png"/>'
|
||||
. ($photo ? '<Relationship Id="rId2" Type="' . $t . 'image" Target="media/photo.jpg"/>' : '')
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private static function proprietes(array $d): string
|
||||
{
|
||||
$date = gmdate('Y-m-d\TH:i:s\Z');
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
||||
. 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||
. 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||
. '<dc:title>' . self::x($d['fichier']) . '</dc:title><dc:creator>IT Club</dc:creator>'
|
||||
. '<dcterms:created xsi:type="dcterms:W3CDTF">' . $date . '</dcterms:created>'
|
||||
. '<dcterms:modified xsi:type="dcterms:W3CDTF">' . $date . '</dcterms:modified>'
|
||||
. '</cp:coreProperties>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Export Word (.docx) du CV au format IT Club.
|
||||
* Écrit le fichier OOXML à la main avec ZipArchive : aucune bibliothèque à installer.
|
||||
* Reçoit le contenu préparé par donnees_cv(), comme la version PDF.
|
||||
*
|
||||
* Unités : twips (1 mm = 56,7), demi-points pour les corps, huitièmes de point pour les filets.
|
||||
*/
|
||||
final class ExportWord
|
||||
{
|
||||
private const NOIR = '111111';
|
||||
private const OR = 'CEB73B';
|
||||
private const OR_TEXTE = '7D6C12';
|
||||
private const GRIS = '555555';
|
||||
private const TRAIT = 'D9D6CC';
|
||||
private const FOND_CONTACT = 'F6F3E4';
|
||||
|
||||
private const PAGE_L = 11906; // A4 : 210 mm
|
||||
private const PAGE_H = 16838; // 297 mm
|
||||
private const MARGE = 794; // 14 mm
|
||||
private const TEXTE_L = self::PAGE_L - 2 * self::MARGE;
|
||||
|
||||
private const NS = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
|
||||
. 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
. 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" '
|
||||
. 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
. 'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"';
|
||||
|
||||
/**
|
||||
* @param array $d contenu renvoyé par donnees_cv()
|
||||
* @param string $logoPng chemin du logo or sur fond transparent
|
||||
* @return string contenu binaire du fichier .docx
|
||||
*/
|
||||
public static function generer(array $d, string $logoPng): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cv');
|
||||
$zip = new ZipArchive();
|
||||
if ($tmp === false || $zip->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 .= '<w:rFonts w:ascii="' . $o['font'] . '" w:hAnsi="' . $o['font'] . '" w:cs="' . $o['font'] . '"/>';
|
||||
}
|
||||
if (!empty($o['b'])) { $p .= '<w:b/>'; }
|
||||
if (!empty($o['i'])) { $p .= '<w:i/>'; }
|
||||
if (!empty($o['caps'])) { $p .= '<w:caps/>'; }
|
||||
if (isset($o['color'])) { $p .= '<w:color w:val="' . $o['color'] . '"/>'; }
|
||||
if (isset($o['spacing'])) { $p .= '<w:spacing w:val="' . $o['spacing'] . '"/>'; }
|
||||
if (isset($o['sz'])) { $p .= '<w:sz w:val="' . $o['sz'] . '"/><w:szCs w:val="' . $o['sz'] . '"/>'; }
|
||||
|
||||
return '<w:r>' . ($p !== '' ? '<w:rPr>' . $p . '</w:rPr>' : '')
|
||||
. '<w:t xml:space="preserve">' . self::x($texte) . '</w:t></w:r>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 .= '<w:pStyle w:val="' . $o['style'] . '"/>'; }
|
||||
if (!empty($o['keepNext'])) { $p .= '<w:keepNext/>'; }
|
||||
if (isset($o['bordureBas'])) {
|
||||
$p .= '<w:pBdr><w:bottom w:val="single" w:sz="' . $o['bordureBas'][0] . '" w:space="' . ($o['bordureBas'][2] ?? 3)
|
||||
. '" w:color="' . $o['bordureBas'][1] . '"/></w:pBdr>';
|
||||
}
|
||||
if (isset($o['tabs'])) { $p .= '<w:tabs>' . $o['tabs'] . '</w:tabs>'; }
|
||||
if (isset($o['avant']) || isset($o['apres']) || isset($o['ligne'])) {
|
||||
$p .= '<w:spacing'
|
||||
. (isset($o['avant']) ? ' w:before="' . $o['avant'] . '"' : '')
|
||||
. (isset($o['apres']) ? ' w:after="' . $o['apres'] . '"' : '')
|
||||
. (isset($o['ligne']) ? ' w:line="' . $o['ligne'] . '" w:lineRule="exact"' : '')
|
||||
. '/>';
|
||||
}
|
||||
if (isset($o['jc'])) { $p .= '<w:jc w:val="' . $o['jc'] . '"/>'; }
|
||||
|
||||
return '<w:p>' . ($p !== '' ? '<w:pPr>' . $p . '</w:pPr>' : '') . $runs . '</w:p>';
|
||||
}
|
||||
|
||||
/** 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 = '<w:tcW w:w="' . $largeur . '" w:type="dxa"/>';
|
||||
if (isset($o['span'])) { $p .= '<w:gridSpan w:val="' . $o['span'] . '"/>'; }
|
||||
if (isset($o['bordures'])) { $p .= '<w:tcBorders>' . $o['bordures'] . '</w:tcBorders>'; }
|
||||
if (isset($o['fond'])) { $p .= '<w:shd w:val="clear" w:color="auto" w:fill="' . $o['fond'] . '"/>'; }
|
||||
if (isset($o['marges'])) {
|
||||
[$h, $dr, $b, $g] = $o['marges'];
|
||||
$p .= '<w:tcMar><w:top w:w="' . $h . '" w:type="dxa"/><w:left w:w="' . $g . '" w:type="dxa"/>'
|
||||
. '<w:bottom w:w="' . $b . '" w:type="dxa"/><w:right w:w="' . $dr . '" w:type="dxa"/></w:tcMar>';
|
||||
}
|
||||
if (isset($o['vAlign'])) { $p .= '<w:vAlign w:val="' . $o['vAlign'] . '"/>'; }
|
||||
|
||||
return '<w:tc><w:tcPr>' . $p . '</w:tcPr>' . $contenu . '</w:tc>';
|
||||
}
|
||||
|
||||
/** Options de ligne : hauteur (exacte), insecable. */
|
||||
private static function tr(string $cellules, array $o = []): string
|
||||
{
|
||||
$p = '';
|
||||
if (!empty($o['insecable'])) { $p .= '<w:cantSplit/>'; }
|
||||
if (isset($o['hauteur'])) { $p .= '<w:trHeight w:val="' . $o['hauteur'] . '" w:hRule="exact"/>'; }
|
||||
return '<w:tr>' . ($p !== '' ? '<w:trPr>' . $p . '</w:trPr>' : '') . $cellules . '</w:tr>';
|
||||
}
|
||||
|
||||
/** 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 .= '<w:gridCol w:w="' . $l . '"/>';
|
||||
}
|
||||
return '<w:tbl><w:tblPr><w:tblW w:w="' . array_sum($colonnes) . '" w:type="dxa"/>'
|
||||
. '<w:tblInd w:w="' . $retrait . '" w:type="dxa"/><w:tblLayout w:type="fixed"/>'
|
||||
. '<w:tblCellMar><w:left w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tblCellMar></w:tblPr>'
|
||||
. '<w:tblGrid>' . $grille . '</w:tblGrid>' . $lignes . '</w:tbl>';
|
||||
}
|
||||
|
||||
private static function filet(string $cote, int $taille, string $couleur): string
|
||||
{
|
||||
return '<w:' . $cote . ' w:val="single" w:sz="' . $taille . '" w:space="0" w:color="' . $couleur . '"/>';
|
||||
}
|
||||
|
||||
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])
|
||||
. '<w:r><w:br/></w:r>'
|
||||
. 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]) . '<w:r><w:tab/></w:r>' . self::r($d['contact']),
|
||||
['avant' => 0, 'apres' => 0, 'tabs' => '<w:tab w:val="right" w:pos="' . (self::TEXTE_L - 400) . '"/>']
|
||||
),
|
||||
['fond' => self::FOND_CONTACT, 'bordures' => self::filet('left', 24, self::OR), 'marges' => [110, 200, 110, 200]]
|
||||
), ['insecable' => true]));
|
||||
$corps .= self::espace();
|
||||
|
||||
$section = '<w:sectPr>'
|
||||
. '<w:headerReference w:type="first" r:id="rId3"/>'
|
||||
. '<w:footerReference w:type="default" r:id="rId4"/><w:footerReference w:type="first" r:id="rId4"/>'
|
||||
. '<w:pgSz w:w="' . self::PAGE_L . '" w:h="' . self::PAGE_H . '"/>'
|
||||
. '<w:pgMar w:top="510" w:right="' . self::MARGE . '" w:bottom="850" w:left="' . self::MARGE . '" w:header="0" w:footer="340" w:gutter="0"/>'
|
||||
. '<w:titlePg/>'
|
||||
. '</w:sectPr>';
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:document ' . self::NS . '><w:body>' . $corps . $section . '</w:body></w:document>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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 = '<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">'
|
||||
. '<wp:extent cx="' . $cx . '" cy="' . $cy . '"/><wp:docPr id="1" name="Logo IT Club" descr="IT Club"/>'
|
||||
. '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic>'
|
||||
. '<pic:nvPicPr><pic:cNvPr id="1" name="logo.png"/><pic:cNvPicPr/></pic:nvPicPr>'
|
||||
. '<pic:blipFill><a:blip r:embed="rId1"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
|
||||
. '<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="' . $cx . '" cy="' . $cy . '"/></a:xfrm>'
|
||||
. '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>'
|
||||
. '</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>';
|
||||
|
||||
$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 '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:hdr ' . self::NS . '>'
|
||||
. self::tbl([self::MARGE, $lLogo, $lNom], $lignes, -self::MARGE)
|
||||
. self::espace()
|
||||
. '</w:hdr>';
|
||||
}
|
||||
|
||||
private static function pied(array $d): string
|
||||
{
|
||||
$petit = ['color' => self::GRIS, 'sz' => 13];
|
||||
$champ = static fn (string $nom): string => '<w:fldSimple w:instr=" ' . $nom . ' ">' . self::r('1', $petit) . '</w:fldSimple>';
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:ftr ' . self::NS . '>'
|
||||
. self::p(
|
||||
'<w:r><w:tab/></w:r>' . self::r($d['mentions'], $petit) . '<w:r><w:tab/></w:r>'
|
||||
. $champ('PAGE') . self::r(' / ', $petit) . $champ('NUMPAGES'),
|
||||
[
|
||||
'avant' => 0, 'apres' => 0,
|
||||
'tabs' => '<w:tab w:val="center" w:pos="' . intdiv(self::TEXTE_L, 2) . '"/><w:tab w:val="right" w:pos="' . self::TEXTE_L . '"/>',
|
||||
]
|
||||
)
|
||||
. '</w:ftr>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fichiers fixes de l'archive
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static function styles(string $langue): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
. '<w:docDefaults>'
|
||||
. '<w:rPrDefault><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial" w:eastAsia="Arial"/>'
|
||||
. '<w:color w:val="' . self::NOIR . '"/><w:sz w:val="19"/><w:szCs w:val="19"/><w:lang w:val="' . $langue . '"/></w:rPr></w:rPrDefault>'
|
||||
. '<w:pPrDefault><w:pPr><w:spacing w:before="0" w:after="40" w:line="252" w:lineRule="auto"/></w:pPr></w:pPrDefault>'
|
||||
. '</w:docDefaults>'
|
||||
. '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
|
||||
. '<w:style w:type="paragraph" w:styleId="Titre2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>'
|
||||
. '<w:pPr><w:keepNext/><w:pBdr><w:bottom w:val="single" w:sz="8" w:space="2" w:color="' . self::OR . '"/></w:pBdr>'
|
||||
. '<w:spacing w:before="150" w:after="70"/><w:outlineLvl w:val="1"/></w:pPr>'
|
||||
. '<w:rPr><w:rFonts w:ascii="Georgia" w:hAnsi="Georgia" w:cs="Georgia"/><w:spacing w:val="6"/><w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr></w:style>'
|
||||
. '</w:styles>';
|
||||
}
|
||||
|
||||
private static function reglages(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
. '<w:defaultTabStop w:val="708"/>'
|
||||
. '<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat>'
|
||||
. '</w:settings>';
|
||||
}
|
||||
|
||||
private static function types(): string
|
||||
{
|
||||
$wml = 'application/vnd.openxmlformats-officedocument.wordprocessingml.';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Default Extension="png" ContentType="image/png"/>'
|
||||
. '<Override PartName="/word/document.xml" ContentType="' . $wml . 'document.main+xml"/>'
|
||||
. '<Override PartName="/word/styles.xml" ContentType="' . $wml . 'styles+xml"/>'
|
||||
. '<Override PartName="/word/settings.xml" ContentType="' . $wml . 'settings+xml"/>'
|
||||
. '<Override PartName="/word/header1.xml" ContentType="' . $wml . 'header+xml"/>'
|
||||
. '<Override PartName="/word/footer1.xml" ContentType="' . $wml . 'footer+xml"/>'
|
||||
. '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||
. '</Types>';
|
||||
}
|
||||
|
||||
private static function relsRacine(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private static function relsDocument(): string
|
||||
{
|
||||
$t = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="' . $t . 'styles" Target="styles.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="' . $t . 'settings" Target="settings.xml"/>'
|
||||
. '<Relationship Id="rId3" Type="' . $t . 'header" Target="header1.xml"/>'
|
||||
. '<Relationship Id="rId4" Type="' . $t . 'footer" Target="footer1.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private static function relsBandeau(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo.png"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private static function proprietes(array $d): string
|
||||
{
|
||||
$date = gmdate('Y-m-d\TH:i:s\Z');
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
||||
. 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||
. 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||
. '<dc:title>' . self::x($d['fichier']) . '</dc:title><dc:creator>IT Club</dc:creator>'
|
||||
. '<dcterms:created xsi:type="dcterms:W3CDTF">' . $date . '</dcterms:created>'
|
||||
. '<dcterms:modified xsi:type="dcterms:W3CDTF">' . $date . '</dcterms:modified>'
|
||||
. '</cp:coreProperties>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Import d'un profil au format cv-profil.schema.json, versions 1.0, 1.1 et 1.2 :
|
||||
* validation, recalcul de la séniorité, enregistrement en base.
|
||||
*/
|
||||
final class Import
|
||||
{
|
||||
public const VERSIONS = ['1.0', '1.1', '1.2'];
|
||||
private const CECRL = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
|
||||
private const GRILLE = ['ecoute', 'lecture', 'production_orale', 'interaction_orale', 'ecriture'];
|
||||
private const CODES_DISPONIBILITE = ['immediate', '1_mois', '2_mois', '3_mois', 'date', 'a_preciser'];
|
||||
private const NIVEAUX_ETUDES = ['bac', 'bac+2', 'bac+3', 'bac+4', 'bac+5', 'doctorat', 'autre'];
|
||||
|
||||
private const DATE_MOIS = '/^\d{4}(-(0[1-9]|1[0-2]))?$/';
|
||||
private const TYPES_EXPERIENCE = ['emploi', 'mission', 'alternance', 'stage', 'independant'];
|
||||
private const TYPES_LIEN = ['linkedin', 'github', 'portfolio', 'site', 'autre'];
|
||||
private const CATEGORIES = ['langage', 'framework', 'base_de_donnees', 'outil', 'cloud', 'methode', 'autre'];
|
||||
private const NIVEAUX_LANGUE = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'natif'];
|
||||
private const NIVEAUX = ['junior', 'intermediaire', 'confirme', 'expert'];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return string[] liste des erreurs, vide si le profil est conforme
|
||||
*/
|
||||
public static function valider(mixed $d): array
|
||||
{
|
||||
if (!self::estObjet($d)) {
|
||||
return [t('Le fichier ne contient pas un objet JSON.')];
|
||||
}
|
||||
$err = [];
|
||||
|
||||
self::cles($err, $d, 'profil',
|
||||
['schema_version', 'identite', 'experiences', 'technologies', 'langues', 'seniorite', 'source'],
|
||||
['titre', 'resume', 'liens', 'formations', 'certifications', 'disponibilite', 'points_attention', 'mots_cles', 'metadonnees',
|
||||
'disponibilite_code', 'disponibilite_date', 'benevolat', 'permis_conduire']);
|
||||
|
||||
if (array_key_exists('schema_version', $d) && !in_array($d['schema_version'], self::VERSIONS, true)) {
|
||||
$err[] = 'schema_version : ' . t('version non gérée (attendu : %s).', implode(', ', self::VERSIONS));
|
||||
}
|
||||
|
||||
// Identité
|
||||
$id = $d['identite'] ?? null;
|
||||
if (self::estObjet($id)) {
|
||||
self::cles($err, $id, 'identite', ['prenom', 'nom'], ['email', 'telephone', 'adresse', 'nationalites']);
|
||||
foreach (self::liste($err, $id, 'nationalites', 'identite') as $i => $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<int, array{id:int, prenom:string, nom:string, titre:?string, archive:bool, corbeille:bool, motifs:string[]}>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Enregistre un profil relu : fiche, CV d'origine, rapport, photo. Commun à l'import d'un CV et à l'import en lot.
|
||||
* @param array $imp fichiers en attente : dossier, cv, rapport, auto, photo
|
||||
* @return int identifiant du candidat créé
|
||||
*/
|
||||
function enregistrer_import(array $profil, array $imp, bool $garderPhoto): int
|
||||
{
|
||||
$pdo = db();
|
||||
$pdo->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<int, array{json:array, cv:array, rapport:?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Photo d'un candidat, gardée pour le reconnaître dans Vivier.
|
||||
* Trouvée dans le CV à l'import (Word, OpenDocument, PDF) ou chargée à la main.
|
||||
* Toujours réenregistrée en JPEG réduit : pas de métadonnées, pas de fichier d'origine exécuté.
|
||||
* La photo ne figure jamais sur les CV exportés.
|
||||
*/
|
||||
final class Photo
|
||||
{
|
||||
private const COTE_MAX = 480; // plus grand côté de la photo gardée, en pixels
|
||||
private const COTE_MIN = 90; // en dessous : icône ou puce, pas un portrait
|
||||
private const TAILLE_MAX_CV = 20 * 1024 * 1024;
|
||||
private const IMAGES_MAX = 60;
|
||||
|
||||
public static function disponible(): bool
|
||||
{
|
||||
return function_exists('imagecreatefromstring');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Recherche de la photo dans un CV
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** @return string|null JPEG prêt à enregistrer, ou null si aucune photo plausible */
|
||||
public static function extraire(string $chemin, string $extension): ?string
|
||||
{
|
||||
if (!self::disponible() || !is_file($chemin) || filesize($chemin) > 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Recherche des profils.
|
||||
*
|
||||
* Chaque profil est découpé en termes normalisés (minuscules, sans accent), rangés dans
|
||||
* index_recherche avec un poids selon l'endroit où le terme apparaît. Une requête est
|
||||
* découpée de la même façon : tous ses mots doivent correspondre, le score classe les résultats.
|
||||
*
|
||||
* Ce moteur remplace l'index FULLTEXT de MySQL, qui ignore les mots de moins de 3 lettres
|
||||
* et les symboles : « C# », « Go », « R » ou « CI/CD » y seraient introuvables.
|
||||
*/
|
||||
final class Recherche
|
||||
{
|
||||
/** Poids d'un terme selon sa provenance. */
|
||||
private const POIDS = [
|
||||
'nom' => 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('/(?<![a-z0-9])\.net\b/', 'dotnet', $t) ?? $t;
|
||||
return preg_replace('/[^\x20-\x7e]/', ' ', $t) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe un texte en termes. Garde les symboles utiles aux noms de technologies : c++, c#, node.js, ci/cd.
|
||||
* @param bool $decomposer ajoute aussi les morceaux des termes composés (« full-stack » donne full-stack, full, stack)
|
||||
* @return string[] termes uniques
|
||||
*/
|
||||
public static function termes(string $texte, bool $decomposer = true): array
|
||||
{
|
||||
preg_match_all('/[a-z0-9](?:[a-z0-9+#.\/\-]*[a-z0-9+#])?/', self::normaliser($texte), $m);
|
||||
$termes = [];
|
||||
foreach ($m[0] as $t) {
|
||||
$t = substr($t, 0, 60);
|
||||
$termes[$t] = true;
|
||||
if ($decomposer && preg_match('/[.\/\-]/', $t)) {
|
||||
foreach (preg_split('/[.\/\-]+/', $t) ?: [] as $morceau) {
|
||||
if ($morceau !== '') {
|
||||
$termes[$morceau] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_map('strval', array_keys($termes));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Indexation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Reconstruit l'index et le texte complet d'un profil à partir de la base. */
|
||||
public static function indexer(PDO $pdo, int $id): void
|
||||
{
|
||||
$lire = static function (string $sql) use ($pdo, $id): array {
|
||||
$r = $pdo->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<int,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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sauvegarde de la base.
|
||||
*
|
||||
* Exporte toutes les tables dans un fichier SQL, le compresse en zip, garde une copie dans stockage/sauvegardes
|
||||
* et l'envoie par email. Lancée chaque semaine par la tâche planifiée de l'hébergement (cron/sauvegarde.php),
|
||||
* ou à la main depuis la page Sauvegarde.
|
||||
*
|
||||
* L'export est écrit en PHP : il ne dépend ni de mysqldump ni d'une commande système.
|
||||
* Les fichiers (CV, photos, pièces jointes) sont trop lourds pour un email : ils se téléchargent à part.
|
||||
*/
|
||||
final class Sauvegarde
|
||||
{
|
||||
/** Tables dont seule la structure est sauvegardée : données temporaires, sans valeur après restauration. */
|
||||
private const SANS_DONNEES = ['tentatives_connexion', 'imports_lot'];
|
||||
|
||||
/** Au-delà de ce délai sans sauvegarde réussie, le tableau de bord alerte. */
|
||||
public const ALERTE_JOURS = 8;
|
||||
|
||||
/** Remplace l'envoi réel dans les tests : fonction (destinataires, sujet, texte, pièce jointe, nom) => 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Aide intégrée : les guides du dossier aide/, écrits en Markdown, affichés dans Vivier.
|
||||
|
||||
/**
|
||||
* Guides proposés. Le guide d'administration est réservé au gestionnaire des comptes.
|
||||
* Le guide Sécurité se lit aussi sans être connecté, depuis la page de connexion.
|
||||
*/
|
||||
const DOCUMENTS_AIDE = ['guide' => 'Guide d\'utilisation', 'securite' => 'Sécurité', 'administration' => 'Guide d\'administration'];
|
||||
|
||||
/**
|
||||
* Prépare un guide pour l'affichage.
|
||||
* Le fichier lu est aide/<guide>.<langue>.md. Sans fichier dans la langue demandée, le français est servi.
|
||||
*
|
||||
* @return array{html:string, chapitres:array<int, array{id:string, titre:string}>, 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('#<h([23])>(.*?)</h\1>#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 '<h' . $m[1] . ' id="' . $id . '">' . $m[2] . '</h' . $m[1] . '>';
|
||||
}, $html) ?? $html;
|
||||
|
||||
// Un tableau large défile dans son cadre au lieu d'élargir la page
|
||||
$html = str_replace(['<table>', '</table>'], ['<div class="defile"><table>', '</table></div>'], $html);
|
||||
|
||||
return ['html' => $html, 'chapitres' => $chapitres, 'langue' => $langue];
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Besoins clients : les missions à pourvoir, les profils proposés, et le rapprochement avec le vivier.
|
||||
|
||||
/** Découpe une liste saisie (« Java, Spring ; Angular ») en éléments propres, sans doublon. */
|
||||
function liste_saisie(?string $texte): array
|
||||
{
|
||||
$vus = [];
|
||||
foreach (preg_split('/[,;\n]+/u', (string) $texte) ?: [] as $v) {
|
||||
$v = trim($v);
|
||||
if ($v !== '') {
|
||||
// La première écriture est gardée : « Java, java » donne « Java »
|
||||
$vus[mb_strtolower($v)] ??= $v;
|
||||
}
|
||||
}
|
||||
return array_values($vus);
|
||||
}
|
||||
|
||||
/** Besoins avec le nombre de profils proposés. */
|
||||
function besoins(string $statut = ''): array
|
||||
{
|
||||
$sql = 'SELECT b.*, u.nom_affiche,
|
||||
(SELECT COUNT(*) FROM besoin_candidat bc JOIN candidats c ON c.id = bc.candidat_id AND c.supprime_le IS NULL
|
||||
WHERE bc.besoin_id = b.id AND bc.etat <> \'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));
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
const VIVIER_RACINE = __DIR__ . '/..';
|
||||
|
||||
require __DIR__ . '/i18n.php';
|
||||
|
||||
// L'ordre fixe celui des colonnes du Kanban, des listes et du tableau de bord :
|
||||
// le parcours d'abord, de Reçu à Embauché, puis Non retenu, qui en sort.
|
||||
const STATUTS = [
|
||||
'recu' => '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 .= '<i' . ($i <= $n ? ' class="plein"' : '') . '></i>';
|
||||
}
|
||||
return '<span class="echelle' . ($taille !== '' ? ' echelle-' . $taille : '') . ($statut === 'non_retenu' ? ' echelle-refus' : '') . '" aria-hidden="true">' . $barres . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '<img class="' . $classe . '" data-initiales="' . e($initiales) . '" src="' . e(url('photo', ['id' => $c['id'], 'v' => substr(basename($c['photo']), 0, 8)])) . '" alt="" loading="lazy" draggable="false" width="64" height="64">';
|
||||
}
|
||||
return '<span class="' . $classe . ' avatar-vide" aria-hidden="true">' . e($initiales) . '</span>';
|
||||
}
|
||||
|
||||
/** 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 '<input type="hidden" name="csrf" value="' . e(jeton_csrf()) . '">';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Journal des erreurs imprévues : stockage/journal/erreurs.log, une ligne par erreur, les 300 dernières.
|
||||
* Aucune donnée de candidat n'y est écrite : la date, le compte, la page, la cause et l'endroit du code.
|
||||
* Le gestionnaire le lit dans Gestion, Erreurs.
|
||||
*/
|
||||
|
||||
const ERREURS_MAX = 300;
|
||||
|
||||
function fichier_erreurs(): string
|
||||
{
|
||||
$dossier = rtrim((string) (config('stockage') ?? (VIVIER_RACINE . '/stockage')), '/') . '/journal';
|
||||
if (!is_dir($dossier)) {
|
||||
@mkdir($dossier, 0700, true);
|
||||
}
|
||||
return $dossier . '/erreurs.log';
|
||||
}
|
||||
|
||||
/** Note une erreur et renvoie sa référence, montrée à la personne. */
|
||||
function consigner_erreur(string $type, string $message, string $fichier, int $ligne): string
|
||||
{
|
||||
$ref = strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
|
||||
// Le message d'une exception PDO peut citer une valeur saisie : on garde le code, pas le détail des valeurs
|
||||
$message = (string) preg_replace("/'[^']{40,}'/u", "'…'", $message);
|
||||
$entree = [
|
||||
'date' => 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 '<!doctype html><html lang="' . e(langue_courante()) . '"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . e(t('Erreur')) . ' · Vivier</title>'
|
||||
. '<link rel="stylesheet" href="assets/style.css"><body><main><section class="carte etroit"><h1>' . e(t('Une erreur est survenue')) . '</h1>'
|
||||
. '<p>' . 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.')) . '</p>'
|
||||
. '<p>' . e(t('Référence à lui donner : %s', $ref)) . '</p>'
|
||||
. '<p><a class="bouton" href="index.php">' . e(t('Retour au Kanban')) . '</a></p></section></main></body></html>';
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* CV au format Europass : contenu préparé une fois, mis en page en HTML (vues/europass.php, enregistrable en PDF)
|
||||
* et en Word (src/ExportEuropass.php).
|
||||
*
|
||||
* Mise en page reprise du CV Europass d'IT Club : bandeau bleu avec le logo Europass, puis À propos de moi,
|
||||
* Éducation et formation, Expérience professionnelle, Compétences linguistiques (grille européenne),
|
||||
* Compétences, Bénévolat, Permis de conduire.
|
||||
*
|
||||
* Règles :
|
||||
* - ni date de naissance ni nationalité ;
|
||||
* - la photo n'apparaît que si on la demande (case « Photo »), jamais sur la version anonymisée ;
|
||||
* - version anonymisée : référence à la place du nom, ni photo ni coordonnées.
|
||||
*/
|
||||
|
||||
/** Libellés des cinq cases de la grille européenne, dans l'ordre des colonnes. */
|
||||
const GRILLE_CECRL = [
|
||||
'ecoute' => '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) . ')' : ''),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Texte brut d'un CV, pour l'envoyer à l'analyse automatique.
|
||||
* Renvoie null pour un PDF : l'API le lit directement.
|
||||
* @throws RuntimeException si le format ne se lit pas
|
||||
*/
|
||||
function texte_du_cv(string $chemin, string $extension): ?string
|
||||
{
|
||||
$lireXml = static function (string $chemin, array $parties, array $finsDeBloc): string {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->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('#</(' . implode('|', $finsDeBloc) . ')>#', "\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);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Traduction.
|
||||
*
|
||||
* Le français est la langue source : le texte français sert de clé. Une autre langue se déclare par
|
||||
* un fichier lang/<code>.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/<code>.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';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Rendu Markdown réduit, pour lire un rapport d'analyse dans Vivier.
|
||||
* Gère : titres, paragraphes, listes, tableaux, blocs de code, gras, italique, code en ligne.
|
||||
* Tout le texte est échappé avant mise en forme : aucun HTML du fichier n'est exécuté. Les liens restent du texte.
|
||||
*/
|
||||
function markdown_vers_html(string $md): string
|
||||
{
|
||||
$enLigne = static function (string $t): string {
|
||||
$t = htmlspecialchars($t, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$t = preg_replace('/`([^`]+)`/', '<code>$1</code>', $t) ?? $t;
|
||||
$t = preg_replace('/\*\*(.+?)\*\*/u', '<strong>$1</strong>', $t) ?? $t;
|
||||
$t = preg_replace('/(?<![\*\w])\*(?!\s)(.+?)(?<!\s)\*(?![\*\w])/u', '<em>$1</em>', $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 .= '<p>' . implode('<br>', array_map($enLigne, $paragraphe)) . "</p>\n";
|
||||
$paragraphe = [];
|
||||
}
|
||||
if ($liste !== null) {
|
||||
$html .= "</$liste>\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 .= '<pre><code>' . htmlspecialchars(implode("\n", $code), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</code></pre>\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 .= "<h$niveau>" . $enLigne($m[2]) . "</h$niveau>\n";
|
||||
} elseif (str_starts_with(ltrim($l), '|') && isset($lignes[$i + 1]) && preg_match('/^\s*\|?[\s:\-|]+\|[\s:\-|]*$/', $lignes[$i + 1])) {
|
||||
$fermer();
|
||||
$html .= '<table><thead><tr>';
|
||||
foreach ($cellules($l) as $c) {
|
||||
$html .= '<th>' . $enLigne($c) . '</th>';
|
||||
}
|
||||
$html .= "</tr></thead><tbody>\n";
|
||||
for ($i += 2; $i < $n && str_starts_with(ltrim($lignes[$i]), '|'); $i++) {
|
||||
$html .= '<tr>';
|
||||
foreach ($cellules($lignes[$i]) as $c) {
|
||||
$html .= '<td>' . $enLigne($c) . '</td>';
|
||||
}
|
||||
$html .= "</tr>\n";
|
||||
}
|
||||
$i--;
|
||||
$html .= "</tbody></table>\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 .= '<li>' . $enLigne($m[3]) . "</li>\n";
|
||||
} elseif (preg_match('/^\s*(-{3,}|\*{3,})\s*$/', $l)) {
|
||||
$fermer();
|
||||
$html .= "<hr>\n";
|
||||
} elseif (trim($l) === '') {
|
||||
$fermer();
|
||||
} else {
|
||||
if ($liste !== null) {
|
||||
$fermer();
|
||||
}
|
||||
$paragraphe[] = trim($l);
|
||||
}
|
||||
}
|
||||
$fermer();
|
||||
return $html;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Pays et nationalités : codes ISO 3166-1 alpha-2, avec leur nom en français et en anglais.
|
||||
* Liste tirée des données Unicode CLDR. Le Kosovo (XK) y figure, avec son code d'usage.
|
||||
*/
|
||||
|
||||
/** Pays dont les ressortissants travaillent au Luxembourg sans permis : Union européenne, Espace économique européen, Suisse. */
|
||||
const PAYS_LIBRE_CIRCULATION = [
|
||||
// Union européenne
|
||||
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU',
|
||||
'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
|
||||
// Espace économique européen
|
||||
'IS', 'LI', 'NO',
|
||||
// Suisse
|
||||
'CH',
|
||||
];
|
||||
|
||||
const PAYS = [
|
||||
'AD' => ['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;
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Sécurité des accès : double authentification, mots de passe, blocage des essais,
|
||||
* journal des connexions, invitations. Le guide « Sécurité » (aide/securite.*.md) explique ces règles.
|
||||
*/
|
||||
|
||||
// Session : fermée après DUREE_INACTIVITE sans activité (bootstrap.php), et dans tous les cas après cette durée
|
||||
const DUREE_SESSION_MAX = 12 * 3600;
|
||||
|
||||
// Blocage : 5 échecs pour un identifiant depuis une même adresse IP bloquent 15 minutes,
|
||||
// puis 30, puis 60 à chaque nouvelle série. 20 échecs en une heure depuis une adresse bloquent cette adresse.
|
||||
const ECHECS_SERIE = 5;
|
||||
const BLOCAGES_MINUTES = [15, 30, 60];
|
||||
const ECHECS_IP_HEURE = 20;
|
||||
|
||||
// Étape de connexion entre le mot de passe et le code : 10 minutes pour la terminer
|
||||
const DUREE_ETAPE = 600;
|
||||
|
||||
const LONGUEUR_MAX_MOT_DE_PASSE = 200;
|
||||
const CODES_SECOURS = 10;
|
||||
const INVITATION_HEURES = 48;
|
||||
const CONNEXIONS_MOIS = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adresse du poste
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function adresse_ip(): string
|
||||
{
|
||||
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
|
||||
}
|
||||
|
||||
/** Réseau d'une adresse : les trois premiers octets en IPv4, les trois premiers groupes en IPv6. */
|
||||
function reseau_ip(string $ip): string
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return implode('.', array_slice(explode('.', $ip), 0, 3)) . '.0/24';
|
||||
}
|
||||
$bin = @inet_pton($ip);
|
||||
if ($bin === false) {
|
||||
return $ip;
|
||||
}
|
||||
$groupes = str_split(bin2hex(substr($bin, 0, 6)), 4);
|
||||
return implode(':', array_map(static fn ($g) => 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 = "<?php\n// Clé de chiffrement des clés de double authentification. Créée par Vivier le " . date('d/m/Y') . ".\n"
|
||||
. "// À garder avec config.php. Sans elle, chaque compte doit réactiver sa double authentification.\n"
|
||||
. 'return ' . var_export($texte, true) . ";\n";
|
||||
if (@file_put_contents($fichier, $contenu, LOCK_EX) === false) {
|
||||
throw new RuntimeException(t('Vivier ne peut pas écrire dans le dossier config/. Ajoutez la clé « cle_secrets » dans config/config.php (voir config.exemple.php).'));
|
||||
}
|
||||
@chmod($fichier, 0600);
|
||||
}
|
||||
}
|
||||
$brut = base64_decode($texte, true);
|
||||
if ($brut === false || strlen($brut) < 32) {
|
||||
throw new RuntimeException(t('La clé « cle_secrets » de la configuration est invalide : 32 octets en base64 attendus.'));
|
||||
}
|
||||
return $cle = substr($brut, 0, 32);
|
||||
}
|
||||
|
||||
function chiffrer(string $clair): string
|
||||
{
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$chiffre = openssl_encrypt($clair, 'aes-256-gcm', cle_secrets(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($chiffre === false) {
|
||||
throw new RuntimeException(t('Chiffrement impossible.'));
|
||||
}
|
||||
return 'v1:' . base64_encode($iv . $tag . $chiffre);
|
||||
}
|
||||
|
||||
/** @return ?string null si le texte est illisible (clé changée ou donnée abîmée) */
|
||||
function dechiffrer(?string $texte): ?string
|
||||
{
|
||||
if ($texte === null || !str_starts_with($texte, 'v1:')) {
|
||||
return null;
|
||||
}
|
||||
$brut = base64_decode(substr($texte, 3), true);
|
||||
if ($brut === false || strlen($brut) < 29) {
|
||||
return null;
|
||||
}
|
||||
$clair = openssl_decrypt(substr($brut, 28), 'aes-256-gcm', cle_secrets(), OPENSSL_RAW_DATA, substr($brut, 0, 12), substr($brut, 12, 16));
|
||||
return $clair === false ? null : $clair;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codes à usage unique (RFC 6238) : ceux des applications Google Authenticator, Microsoft Authenticator, Bitwarden…
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final class Totp
|
||||
{
|
||||
private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
public const PERIODE = 30;
|
||||
|
||||
/** Clé nouvelle, en base32 : 160 bits, comme le recommande la RFC 4226. */
|
||||
public static function nouvelleCle(): string
|
||||
{
|
||||
return self::base32(random_bytes(20));
|
||||
}
|
||||
|
||||
public static function base32(string $octets): string
|
||||
{
|
||||
$bits = '';
|
||||
foreach (str_split($octets) as $o) {
|
||||
$bits .= str_pad(decbin(ord($o)), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$texte = '';
|
||||
foreach (str_split($bits, 5) as $morceau) {
|
||||
$texte .= self::ALPHABET[bindec(str_pad($morceau, 5, '0'))];
|
||||
}
|
||||
return $texte;
|
||||
}
|
||||
|
||||
public static function base32Decoder(string $texte): string
|
||||
{
|
||||
$texte = strtoupper((string) preg_replace('/[\s=-]+/', '', $texte));
|
||||
$bits = '';
|
||||
foreach (str_split($texte) as $c) {
|
||||
$v = strpos(self::ALPHABET, $c);
|
||||
if ($v === false) {
|
||||
return '';
|
||||
}
|
||||
$bits .= str_pad(decbin($v), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$octets = '';
|
||||
foreach (str_split($bits, 8) as $morceau) {
|
||||
if (strlen($morceau) === 8) {
|
||||
$octets .= chr(bindec($morceau));
|
||||
}
|
||||
}
|
||||
return $octets;
|
||||
}
|
||||
|
||||
public static function pas(?int $quand = null): int
|
||||
{
|
||||
return intdiv($quand ?? time(), self::PERIODE);
|
||||
}
|
||||
|
||||
/** Code à six chiffres pour un pas de temps. */
|
||||
public static function code(string $cle, int $pas): string
|
||||
{
|
||||
$h = hash_hmac('sha1', pack('J', $pas), self::base32Decoder($cle), true);
|
||||
$o = ord($h[19]) & 0x0f;
|
||||
$n = ((ord($h[$o]) & 0x7f) << 24) | (ord($h[$o + 1]) << 16) | (ord($h[$o + 2]) << 8) | ord($h[$o + 3]);
|
||||
return str_pad((string) ($n % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie un code saisi. Accepte le pas courant, celui d'avant et celui d'après (horloge du téléphone en décalage).
|
||||
* Un code déjà accepté ($dernierPas) est refusé : il ne sert qu'une fois.
|
||||
* @return ?int le pas du code accepté, ou null
|
||||
*/
|
||||
public static function verifier(string $cle, string $saisie, ?int $dernierPas, ?int $quand = null): ?int
|
||||
{
|
||||
$saisie = (string) preg_replace('/\D+/', '', $saisie);
|
||||
if (strlen($saisie) !== 6) {
|
||||
return null;
|
||||
}
|
||||
$maintenant = self::pas($quand);
|
||||
foreach ([0, -1, 1] as $ecart) {
|
||||
$p = $maintenant + $ecart;
|
||||
if (hash_equals(self::code($cle, $p), $saisie)) {
|
||||
return $dernierPas !== null && $p <= $dernierPas ? null : $p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Adresse otpauth:// lue par les applications, dans le QR code. */
|
||||
public static function adresse(string $cle, string $compte, string $emetteur): string
|
||||
{
|
||||
return 'otpauth://totp/' . rawurlencode($emetteur . ':' . $compte)
|
||||
. '?secret=' . $cle . '&issuer=' . rawurlencode($emetteur) . '&algorithm=SHA1&digits=6&period=' . self::PERIODE;
|
||||
}
|
||||
|
||||
/** Clé découpée en groupes de quatre, pour la saisie à la main. */
|
||||
public static function lisible(string $cle): string
|
||||
{
|
||||
return implode(' ', str_split($cle, 4));
|
||||
}
|
||||
}
|
||||
|
||||
/** Codes de secours : dix codes de huit caractères, chacun utilisable une fois. Renvoie les codes en clair, une seule fois. */
|
||||
function generer_codes_secours(int $utilisateurId): array
|
||||
{
|
||||
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; // sans 0, o, 1, l, i : rien à confondre
|
||||
$codes = [];
|
||||
$empreintes = [];
|
||||
for ($i = 0; $i < CODES_SECOURS; $i++) {
|
||||
$c = '';
|
||||
for ($j = 0; $j < 8; $j++) {
|
||||
$c .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
||||
}
|
||||
$codes[] = substr($c, 0, 4) . '-' . substr($c, 4);
|
||||
$empreintes[] = password_hash($c, PASSWORD_DEFAULT);
|
||||
}
|
||||
db()->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<int, array{cle:string, type:string, identifiant:string, ip:string, echecs:int, fin:int}>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Suivi d'un candidat : archivage, entretiens, relances, consentement, export de ses données.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Archivage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Archive ou désarchive un profil. Un profil archivé sort du Kanban, des chiffres et de la recherche courante.
|
||||
* Il garde ses données, ses documents et son échéance de conservation.
|
||||
*/
|
||||
function archiver_candidat(array $c, bool $archiver): void
|
||||
{
|
||||
if (($c['archive_le'] !== null) === $archiver) {
|
||||
return;
|
||||
}
|
||||
if ($archiver) {
|
||||
db()->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;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Traduction du contenu des profils.
|
||||
*
|
||||
* Le contenu d'un profil mêle deux langues : les intitulés et les descriptions suivent la langue du CV,
|
||||
* la synthèse et les points d'attention sont rédigés en français (consignes du projet Claude).
|
||||
* Pour qu'un compte voie tout dans la langue de son interface, Vivier garde une traduction par langue.
|
||||
*
|
||||
* Une traduction est « à jour » tant que le texte d'origine n'a pas changé (empreinte du paquet).
|
||||
* Les profils à traduire attendent dans traductions_a_faire. Le navigateur d'un compte connecté les traite
|
||||
* un par un, en arrière-plan, quand l'analyse automatique est configurée.
|
||||
*
|
||||
* Les textes saisis par l'équipe (commentaires, comptes rendus, besoins, notes) ne sont pas traduits.
|
||||
*/
|
||||
|
||||
/** Essais au plus pour un profil de la file, avant d'abandonner jusqu'à une relance manuelle. */
|
||||
const TRADUCTION_ESSAIS = 3;
|
||||
|
||||
/** Langue dans laquelle le CV d'origine est rédigé, d'après l'analyse. Français par défaut. */
|
||||
function langue_du_profil(array $c): string
|
||||
{
|
||||
$l = strtolower(substr((string) (json_decode((string) $c['json_source'], true)['source']['langue_cv'] ?? ''), 0, 2));
|
||||
return preg_match('/^[a-z]{2}$/', $l) ? $l : 'fr';
|
||||
}
|
||||
|
||||
/** Langues présentes dans le contenu : celle du CV, et le français des synthèses. */
|
||||
function langues_du_contenu(array $c): array
|
||||
{
|
||||
return array_values(array_unique([langue_du_profil($c), 'fr']));
|
||||
}
|
||||
|
||||
/** Faut-il une traduction pour lire ce profil dans cette langue ? */
|
||||
function traduction_necessaire(array $c, string $langue): bool
|
||||
{
|
||||
return langues_du_contenu($c) !== [$langue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Textes libres du profil qui se traduisent : le « paquet » envoyé à la traduction.
|
||||
* Les clés numériques sont les identifiants des lignes (expériences, formations, certifications).
|
||||
*/
|
||||
function paquet_traduction(array $c): array
|
||||
{
|
||||
$paquet = array_filter([
|
||||
'titre' => (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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// Tests de l'aide intégrée : php tests/test_aide.php
|
||||
declare(strict_types=1);
|
||||
const VIVIER_RACINE = __DIR__ . '/..';
|
||||
const SENIORITES = ['junior' => '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 (['<h3' => 'sous-titres', '<table>' => 'tableaux', '<ol>' => 'listes numérotées', '<tr>' => '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);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// Tests de la traduction : php tests/test_i18n.php
|
||||
declare(strict_types=1);
|
||||
const VIVIER_RACINE = __DIR__ . '/..';
|
||||
require __DIR__ . '/../src/i18n.php';
|
||||
|
||||
$ok = 0; $ko = 0;
|
||||
function verifier(string $nom, bool $cond): void {
|
||||
global $ok, $ko;
|
||||
if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; }
|
||||
}
|
||||
|
||||
verifier('français : le texte est rendu tel quel', t('Profils') === 'Profils');
|
||||
verifier('français : variables', t('Conservation prolongée de %d mois.', 24) === 'Conservation prolongée de 24 mois.');
|
||||
verifier('français : accord', tn(1, '%d profil', '%d profils') === '1 profil' && tn(3, '%d profil', '%d profils') === '3 profils');
|
||||
verifier('français : nombre', nombre(9.10) === '9,1' && nombre(17.0) === '17' && annees(1) === '1 an' && annees(9.1) === '9,1 ans');
|
||||
|
||||
langue_courante('en');
|
||||
verifier('anglais : traduction', t('Profils') === 'Profiles');
|
||||
verifier('anglais : variables', t('Conservation prolongée de %d mois.', 24) === 'Retention extended by 24 months.');
|
||||
verifier('anglais : accord', tn(1, '%d profil', '%d profils') === '1 profile' && tn(3, '%d profil', '%d profils') === '3 profiles');
|
||||
verifier('anglais : nombre', nombre(9.10) === '9.1' && annees(9.1) === '9.1 years');
|
||||
verifier('anglais : clé absente rendue en français', t('Texte jamais traduit') === 'Texte jamais traduit');
|
||||
verifier('anglais : journal à partie variable', t_journal('Import de CV_Test.pdf') === 'Import of CV_Test.pdf' && t_journal('Traduction en anglais') === 'Translation into English');
|
||||
verifier('langue inconnue ignorée', langue_courante('xx') === 'en');
|
||||
verifier('allemand : libellés du CV', traduire('Compétences techniques', 'de') === 'Technische Kenntnisse' && annees(9.1, 'de') === '9,1 Jahre');
|
||||
|
||||
// Chaque traduction garde les mêmes variables que le texte français
|
||||
foreach (['en', 'de'] as $langue) {
|
||||
$ecarts = [];
|
||||
foreach (dictionnaire($langue) as $fr => $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);
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
// Tests de l'import : php tests/test_import.php
|
||||
declare(strict_types=1);
|
||||
const VIVIER_RACINE = __DIR__ . '/..';
|
||||
require __DIR__ . '/../src/i18n.php';
|
||||
require __DIR__ . '/../src/pays.php';
|
||||
require __DIR__ . '/../src/Import.php';
|
||||
|
||||
$ok = 0; $ko = 0;
|
||||
function verifier(string $nom, bool $cond): void {
|
||||
global $ok, $ko;
|
||||
if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; }
|
||||
}
|
||||
|
||||
$exemple = json_decode(file_get_contents(__DIR__ . '/../claude/exemple-profil.json'), true);
|
||||
$auj = new DateTimeImmutable('2026-10-08');
|
||||
|
||||
verifier('exemple conforme', Import::valider($exemple) === []);
|
||||
|
||||
$p = $exemple; $p['seniorite']['niveau'] = 'senior';
|
||||
verifier('niveau inconnu rejeté', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['experiences'][0]['date_debut'] = '03/2020';
|
||||
verifier('date mal formée rejetée', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['age'] = 34;
|
||||
verifier('clé en trop rejetée', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; unset($p['identite']['nom']);
|
||||
verifier('nom manquant rejeté', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['liens'][0]['url'] = 'javascript:alert(1)';
|
||||
verifier('lien non http rejeté', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['experiences'][1]['date_fin'] = '2016-01';
|
||||
verifier('fin avant début rejetée', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['schema_version'] = '2.0';
|
||||
verifier('version inconnue rejetée', count(Import::valider($p)) === 1);
|
||||
verifier('liste rejetée', Import::valider([1, 2]) !== []);
|
||||
verifier('texte rejeté', Import::valider('x') !== []);
|
||||
|
||||
// Format 1.1 : mots-clés et métadonnées
|
||||
$v10 = $exemple; $v10['schema_version'] = '1.0'; unset($v10['mots_cles'], $v10['metadonnees']);
|
||||
verifier('profil 1.0 toujours accepté', Import::valider($v10) === []);
|
||||
$p = $exemple; $p['metadonnees']['niveau_etudes'] = 'master';
|
||||
verifier('niveau d\'études inconnu rejeté', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['mots_cles'][] = 42;
|
||||
verifier('mot-clé non textuel rejeté', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['metadonnees']['encadrement'] = 'oui';
|
||||
verifier('encadrement non booléen rejeté', count(Import::valider($p)) === 1);
|
||||
$p = $exemple; $p['metadonnees']['salaire'] = 1;
|
||||
verifier('métadonnée inconnue rejetée', count(Import::valider($p)) === 1);
|
||||
|
||||
// Séniorité
|
||||
$s = Import::seniorite($exemple, $auj);
|
||||
verifier('exemple : 9,1 ans', $s['annees'] === 9.1);
|
||||
verifier('exemple : confirmé', $s['niveau'] === 'confirme');
|
||||
verifier('exemple : pas d\'écart', $s['ecart'] === false);
|
||||
|
||||
$exp = static fn (string $d, ?string $f, string $t = 'emploi', array $tech = []) =>
|
||||
['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);
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
// Tests de la recherche de photo dans un CV : php tests/test_photo.php
|
||||
declare(strict_types=1);
|
||||
const VIVIER_RACINE = __DIR__ . '/..';
|
||||
require __DIR__ . '/../src/i18n.php';
|
||||
require __DIR__ . '/../src/Photo.php';
|
||||
|
||||
$ok = 0; $ko = 0;
|
||||
function verifier(string $nom, bool $cond): void {
|
||||
global $ok, $ko;
|
||||
if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; }
|
||||
}
|
||||
if (!Photo::disponible()) { echo "Extension GD absente : tests ignorés.\n"; exit(0); }
|
||||
|
||||
/** Image aux teintes variées, comme une photo. */
|
||||
function portrait(int $l, int $h, string $format = 'jpeg'): string {
|
||||
$img = imagecreatetruecolor($l, $h);
|
||||
mt_srand(7);
|
||||
for ($y = 0; $y < $h; $y += 4) {
|
||||
for ($x = 0; $x < $l; $x += 4) {
|
||||
imagefilledrectangle($img, $x, $y, $x + 3, $y + 3, imagecolorallocate($img, ($x * 255 / $l + mt_rand(0, 60)) % 256, ($y * 255 / $h + mt_rand(0, 60)) % 256, mt_rand(60, 200)));
|
||||
}
|
||||
}
|
||||
ob_start(); $format === 'png' ? imagepng($img) : imagejpeg($img, null, 90); return (string) ob_get_clean();
|
||||
}
|
||||
/** Image à deux couleurs, comme un logo. */
|
||||
function logo(int $l, int $h): string {
|
||||
$img = imagecreatetruecolor($l, $h);
|
||||
imagefill($img, 0, 0, imagecolorallocate($img, 255, 255, 255));
|
||||
imagefilledrectangle($img, (int) ($l / 4), (int) ($h / 4), (int) ($l * 3 / 4), (int) ($h * 3 / 4), imagecolorallocate($img, 206, 183, 59));
|
||||
ob_start(); imagepng($img); return (string) ob_get_clean();
|
||||
}
|
||||
function dimensions(?string $jpeg): array { $t = $jpeg ? getimagesizefromstring($jpeg) : [0, 0, 0]; return [$t[0], $t[1], $t[2]]; }
|
||||
|
||||
$tmp = sys_get_temp_dir() . '/vivier-test-' . bin2hex(random_bytes(4));
|
||||
mkdir($tmp);
|
||||
|
||||
// --- Word : la photo parmi un logo et un bandeau
|
||||
$zip = new ZipArchive(); $zip->open("$tmp/cv.docx", ZipArchive::CREATE);
|
||||
$zip->addFromString('word/document.xml', '<w:document/>');
|
||||
$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', '<w:document/>');
|
||||
$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('<?php echo 1;') === null);
|
||||
|
||||
array_map('unlink', glob("$tmp/*")); rmdir($tmp);
|
||||
echo "$ok réussis, $ko échoués\n";
|
||||
exit($ko > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
// Tests du découpage en termes et du lecteur de rapports : php tests/test_recherche.php
|
||||
declare(strict_types=1);
|
||||
const SENIORITES = ['junior' => '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 <script>x</script>\n\n- un\n- deux\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
verifier('titre décalé d\'un niveau', str_contains($html, '<h2>Titre</h2>'));
|
||||
verifier('gras', str_contains($html, '<strong>gras</strong>'));
|
||||
verifier('HTML du fichier neutralisé', !str_contains($html, '<script>') && str_contains($html, '<script>'));
|
||||
verifier('liste', str_contains($html, "<ul>\n<li>un</li>\n<li>deux</li>\n</ul>"));
|
||||
verifier('tableau', str_contains($html, '<th>a</th>') && str_contains($html, '<td>2</td>'));
|
||||
|
||||
echo "$ok réussis, $ko échoués\n";
|
||||
exit($ko > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
// Tests de la version 1.8 sans base de données : php tests/test_securite.php
|
||||
// Double authentification, blocages, nationalités, disponibilité, traduction, CV Europass, import 1.2.
|
||||
declare(strict_types=1);
|
||||
const VIVIER_RACINE = __DIR__ . '/..';
|
||||
const LONGUEUR_MOT_DE_PASSE = 12;
|
||||
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 TELETRAVAIL = ['non' => 'Sur site', 'partiel' => 'Télétravail partiel', 'complet' => 'Télétravail complet'];
|
||||
require __DIR__ . '/../src/i18n.php';
|
||||
require __DIR__ . '/../src/pays.php';
|
||||
require __DIR__ . '/../src/securite.php';
|
||||
require __DIR__ . '/../src/Import.php';
|
||||
require __DIR__ . '/../src/depot.php';
|
||||
require __DIR__ . '/../src/traduction.php';
|
||||
require __DIR__ . '/../src/europass.php';
|
||||
|
||||
// Copie de src/bootstrap.php, qui ne se charge pas sans configuration
|
||||
function date_fr(?string $date, bool $heure = false): string
|
||||
{
|
||||
return $date ? (new DateTimeImmutable($date))->format($heure ? 'd/m/Y H:i' : 'd/m/Y') : '';
|
||||
}
|
||||
|
||||
$ok = 0; $ko = 0;
|
||||
function verifier(string $nom, bool $cond): void {
|
||||
global $ok, $ko;
|
||||
if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; }
|
||||
}
|
||||
|
||||
// --- Codes à usage unique : vecteurs de la RFC 6238 (SHA-1, clé « 12345678901234567890 »), six derniers chiffres
|
||||
$cle = Totp::base32('12345678901234567890');
|
||||
verifier('base32 : encodage de la clé de la RFC', $cle === 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');
|
||||
verifier('base32 : aller-retour', Totp::base32Decoder($cle) === '12345678901234567890');
|
||||
verifier('base32 : espaces, tirets et minuscules ignorés', Totp::base32Decoder('gezd gnbv-gy3t qojq gezd gnbv gy3t qojq') === '12345678901234567890');
|
||||
verifier('base32 : caractère interdit', Totp::base32Decoder('GEZ1') === '');
|
||||
foreach ([59 => '287082', 1111111109 => '081804', 1111111111 => '050471', 1234567890 => '005924', 2000000000 => '279037', 20000000000 => '353130'] as $t => $attendu) {
|
||||
verifier("RFC 6238 : T = $t", Totp::code($cle, Totp::pas($t)) === $attendu);
|
||||
}
|
||||
$t = 1234567890;
|
||||
verifier('vérification : code courant accepté', Totp::verifier($cle, '005924', null, $t) === Totp::pas($t));
|
||||
verifier('vérification : espaces tolérés', Totp::verifier($cle, '005 924', null, $t) === Totp::pas($t));
|
||||
verifier('vérification : pas précédent accepté (horloge en retard)', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) - 1), null, $t) === Totp::pas($t) - 1);
|
||||
verifier('vérification : pas suivant accepté (horloge en avance)', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) + 1), null, $t) === Totp::pas($t) + 1);
|
||||
verifier('vérification : deux pas d\'écart refusés', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) - 2), null, $t) === null);
|
||||
verifier('vérification : code rejoué refusé', Totp::verifier($cle, '005924', Totp::pas($t), $t) === null);
|
||||
verifier('vérification : code plus ancien que le dernier refusé', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) - 1), Totp::pas($t), $t) === null);
|
||||
verifier('vérification : longueur fausse', Totp::verifier($cle, '05924', null, $t) === null && Totp::verifier($cle, '', null, $t) === null);
|
||||
$nouvelle = Totp::nouvelleCle();
|
||||
verifier('nouvelle clé : 32 caractères base32, 160 bits', strlen($nouvelle) === 32 && strlen(Totp::base32Decoder($nouvelle)) === 20);
|
||||
verifier('adresse otpauth', str_starts_with(Totp::adresse($cle, 'yann', 'Vivier'), 'otpauth://totp/Vivier%3Ayann?secret=' . $cle . '&issuer=Vivier'));
|
||||
verifier('clé lisible par groupes de 4', Totp::lisible('ABCDEFGH') === 'ABCD EFGH');
|
||||
|
||||
// --- Blocages : 5 échecs = 15 min, 10 = 30 min, 15 = 60 min ; adresse : 20 échecs dans l'heure
|
||||
$maintenant = 1_800_000_000;
|
||||
$serie = static fn (int $n, int $dernier): array => array_map(static fn ($i) => $dernier - ($n - 1 - $i) * 10, range(0, $n - 1));
|
||||
verifier('blocage : 4 échecs, pas de blocage', fin_blocage_compte($serie(4, $maintenant), $maintenant) === null);
|
||||
verifier('blocage : 5 échecs, 15 minutes', fin_blocage_compte($serie(5, $maintenant), $maintenant) === $maintenant + 900);
|
||||
verifier('blocage : levé après 15 minutes', fin_blocage_compte($serie(5, $maintenant - 901), $maintenant) === null);
|
||||
$m = array_merge($serie(5, $maintenant - 2000), $serie(5, $maintenant));
|
||||
verifier('blocage : 10 échecs, 30 minutes', fin_blocage_compte($m, $maintenant) === $maintenant + 1800);
|
||||
$m = array_merge($serie(10, $maintenant - 5000), $serie(5, $maintenant));
|
||||
verifier('blocage : 15 échecs, 60 minutes', fin_blocage_compte($m, $maintenant) === $maintenant + 3600);
|
||||
$m = array_merge($serie(15, $maintenant - 9000), $serie(5, $maintenant));
|
||||
verifier('blocage : au-delà, 60 minutes', fin_blocage_compte($m, $maintenant) === $maintenant + 3600);
|
||||
verifier('blocage : 6e échec après la fin du premier blocage, pas de nouveau blocage', fin_blocage_compte(array_merge($serie(5, $maintenant - 1000), [$maintenant]), $maintenant) === null);
|
||||
verifier('adresse : 19 échecs dans l\'heure, pas de blocage', fin_blocage_ip($serie(19, $maintenant), $maintenant) === null);
|
||||
$m = $serie(20, $maintenant);
|
||||
verifier('adresse : 20 échecs dans l\'heure, bloquée une heure après le premier', fin_blocage_ip($m, $maintenant) === $m[0] + 3600);
|
||||
verifier('adresse : échecs de plus d\'une heure ignorés', fin_blocage_ip($serie(20, $maintenant - 3700), $maintenant) === null);
|
||||
|
||||
// --- Réseau, navigateur
|
||||
verifier('réseau IPv4 : /24', reseau_ip('192.168.12.34') === '192.168.12.0/24');
|
||||
verifier('réseau IPv6 : /48', reseau_ip('2001:db8:abcd:12::1') === '2001:db8:abcd::/48');
|
||||
verifier('navigateur lisible', navigateur_lisible('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36') === 'Chrome, Windows');
|
||||
verifier('navigateur lisible : Edge sur Mac', navigateur_lisible('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Chrome/129.0 Safari/537.36 Edg/129.0') === 'Edge, macOS');
|
||||
|
||||
// --- Nationalités
|
||||
verifier('nationalités : codes ISO, doublons et inconnus écartés', codes_nationalites('fr, LU ;fr,XX') === ['FR', 'LU']);
|
||||
verifier('nationalités : 4 au plus', count(codes_nationalites(['FR', 'LU', 'BE', 'DE', 'IT'])) === 4);
|
||||
verifier('hors UE : aucune nationalité, inconnu', hors_ue([]) === null);
|
||||
verifier('hors UE : Union européenne', hors_ue(['FR']) === 0);
|
||||
verifier('hors UE : EEE et Suisse comptent comme UE', hors_ue(['NO']) === 0 && hors_ue(['CH']) === 0 && hors_ue(['IS']) === 0 && hors_ue(['LI']) === 0);
|
||||
verifier('hors UE : Maroc', hors_ue(['MA']) === 1);
|
||||
verifier('hors UE : double nationalité dont une européenne', hors_ue(['MA', 'FR']) === 0);
|
||||
verifier('hors UE : Royaume-Uni hors UE', hors_ue(['GB']) === 1);
|
||||
verifier('nom de pays : français et anglais', nom_pays('DE', 'fr') === 'Allemagne' && nom_pays('DE', 'en') === 'Germany');
|
||||
verifier('liste des pays : 27 pays de l\'UE plus EEE et Suisse', count(PAYS_LIBRE_CIRCULATION) === 31);
|
||||
|
||||
// --- Disponibilité
|
||||
$auj = new DateTimeImmutable('2026-10-15');
|
||||
verifier('disponibilité : date française', classer_disponibilite('Disponible le 01/12/2026', $auj) === ['code' => 'date', 'date' => '2026-12-01']);
|
||||
verifier('disponibilité : date ISO', classer_disponibilite('à partir du 2027-01-04', $auj) === ['code' => 'date', 'date' => '2027-01-04']);
|
||||
verifier('disponibilité : immédiate', classer_disponibilite('Immédiate', $auj)['code'] === 'immediate' && classer_disponibilite('Available now', $auj)['code'] === 'immediate');
|
||||
verifier('disponibilité : préavis de 2 mois', classer_disponibilite('Préavis de 2 mois', $auj) === ['code' => '2_mois', 'date' => '2026-12-15']);
|
||||
verifier('disponibilité : one month notice', classer_disponibilite('One month notice', $auj)['code'] === '1_mois');
|
||||
verifier('disponibilité : 3 mois', classer_disponibilite('trois mois de préavis', $auj)['code'] === '3_mois');
|
||||
verifier('disponibilité : 12 mois ne passe pas pour 2 mois', classer_disponibilite('12 mois', $auj)['code'] === 'a_preciser');
|
||||
verifier('disponibilité : texte flou', classer_disponibilite('à discuter', $auj) === ['code' => 'a_preciser', 'date' => null]);
|
||||
verifier('disponibilité : vide', classer_disponibilite(' ', $auj) === ['code' => null, 'date' => null]);
|
||||
verifier('libellé : sous 2 mois avec estimation', libelle_disponibilite(['disponibilite_code' => '2_mois', 'disponible_le' => '2026-12-15'], 'fr') === 'Sous 2 mois · vers le 15/12/2026');
|
||||
verifier('libellé : sans estimation sur un CV', libelle_disponibilite(['disponibilite_code' => '2_mois', 'disponible_le' => '2026-12-15'], 'en', false) === 'Within 2 months');
|
||||
verifier('libellé : date précise', libelle_disponibilite(['disponibilite_code' => 'date', 'disponible_le' => '2027-01-04'], 'de') === 'Ab 04/01/2027');
|
||||
verifier('libellé : aucun code', libelle_disponibilite(['disponibilite_code' => null, 'disponible_le' => null], 'fr') === '');
|
||||
|
||||
// --- Traduction
|
||||
verifier('langues du contenu : CV anglais', langues_du_contenu(['source_langue' => 'en', 'json_source' => '{"source":{"langue_cv":"en"}}']) !== []);
|
||||
$paquet = ['titre' => 'Développeur', 'experiences' => ['12' => ['poste' => 'Chef de projet', 'description' => 'Pilotage']], 'formations' => ['7' => 'Master']];
|
||||
[$fusion, $n] = fusionner_traduction($paquet, ['titre' => 'Developer', 'experiences' => ['12' => ['poste' => 'Project manager']], 'formations' => ['7' => ' '], 'pirate' => 'x']);
|
||||
verifier('fusion : textes traduits repris', $fusion['titre'] === 'Developer' && $fusion['experiences']['12']['poste'] === 'Project manager');
|
||||
verifier('fusion : texte manquant ou vide, original gardé', $fusion['experiences']['12']['description'] === 'Pilotage' && $fusion['formations']['7'] === 'Master');
|
||||
verifier('fusion : clé inconnue ignorée', !isset($fusion['pirate']));
|
||||
verifier('fusion : nombre de textes traduits', $n === 2);
|
||||
verifier('empreinte : les métiers ne comptent pas', empreinte_paquet($paquet) === empreinte_paquet($paquet + ['metiers' => ['développeur' => 'developer']]));
|
||||
verifier('empreinte : un texte changé la change', empreinte_paquet($paquet) !== empreinte_paquet(['titre' => 'Architecte'] + $paquet));
|
||||
|
||||
// --- Import au format 1.2
|
||||
$exemple = json_decode((string) file_get_contents(__DIR__ . '/../claude/exemple-profil.json'), true);
|
||||
verifier('import 1.2 : l\'exemple est valide', Import::valider($exemple) === []);
|
||||
$e = $exemple; $e['identite']['nationalites'] = ['FRA'];
|
||||
verifier('import 1.2 : nationalité sur trois lettres refusée', count(Import::valider($e)) === 1);
|
||||
$e = $exemple; $e['disponibilite_code'] = 'date'; $e['disponibilite_date'] = null;
|
||||
verifier('import 1.2 : date obligatoire pour « date »', count(Import::valider($e)) === 1);
|
||||
$e = $exemple; $e['disponibilite_code'] = 'bientot';
|
||||
verifier('import 1.2 : code de disponibilité inconnu', count(Import::valider($e)) === 1);
|
||||
$e = $exemple; $e['langues'][1]['cecrl'] = ['ecoute' => 'C1', 'lecture' => 'C2', 'production_orale' => 'B2', 'interaction_orale' => 'B2', 'ecriture' => 'C1'];
|
||||
verifier('import 1.2 : grille européenne acceptée', Import::valider($e) === []);
|
||||
$e['langues'][1]['cecrl']['ecoute'] = 'D1';
|
||||
verifier('import 1.2 : niveau de grille inconnu refusé', count(Import::valider($e)) === 1);
|
||||
$e = $exemple; $e['schema_version'] = '1.1'; unset($e['identite']['nationalites'], $e['disponibilite_code'], $e['disponibilite_date'], $e['benevolat'], $e['permis_conduire']);
|
||||
foreach ($e['experiences'] as &$x) { unset($x['projet'], $x['mission'], $x['charge']); } unset($x);
|
||||
foreach ($e['formations'] as &$f) { unset($f['date_debut'], $f['date_fin'], $f['lieu']); } unset($f);
|
||||
foreach ($e['langues'] as &$l) { unset($l['cecrl']); } unset($l);
|
||||
verifier('import 1.1 : toujours accepté', Import::valider($e) === []);
|
||||
$colonnes = static fn (array $p, DateTimeImmutable $d): array => (new ReflectionMethod(Import::class, 'colonnes'))->invoke(null, $p, $d);
|
||||
$col = $colonnes($exemple, $auj);
|
||||
verifier('import 1.2 : colonnes de nationalité', $col['nationalites'] === 'FR' && $col['hors_ue'] === 0);
|
||||
verifier('import 1.2 : disponibilité classée et date estimée', $col['disponibilite_code'] === '2_mois' && $col['disponible_le'] === '2026-12-15');
|
||||
verifier('import 1.2 : bénévolat et permis', str_contains((string) $col['benevolat'], 'ateliers') && $col['permis_conduire'] === 'B');
|
||||
$col = $colonnes($e, $auj);
|
||||
verifier('import 1.1 : disponibilité classée depuis le texte', $col['disponibilite_code'] === '2_mois' && $col['nationalites'] === null && $col['hors_ue'] === null);
|
||||
|
||||
// --- CV Europass
|
||||
$c = [
|
||||
'id' => 3, 'prenom' => 'Camille', 'nom' => 'EXEMPLE', 'titre' => 'Développeuse Java', 'resume' => "Camille conçoit des API.\nDix ans d'expérience.",
|
||||
'email' => 'camille@example.com', 'telephone' => '+352 1', 'rue' => '1 rue X', 'code_postal' => 'L-1000', 'ville' => 'Luxembourg', 'pays' => 'Luxembourg',
|
||||
'photo' => 'abc.jpg', 'liens' => [['type' => 'linkedin', 'url' => 'https://linkedin.com/in/c']], 'nationalites' => 'MA', 'permis_travail' => 'Titre salarié',
|
||||
'benevolat' => "Ateliers de code\nMaraudes", 'permis_conduire' => 'B, A',
|
||||
'experiences' => [['id' => 1, 'poste' => 'Développeuse', 'employeur' => 'ACME', 'client' => 'Banque X', 'lieu' => 'Luxembourg', 'date_debut' => '2020-03', 'date_fin' => null,
|
||||
'description' => "- Conçoit des API\n- Revue de code", 'technologies' => 'Java, Spring', 'projet' => 'Refonte', 'mission' => 'Développement back-end', 'charge' => '100 %']],
|
||||
'formations' => [['id' => 1, 'diplome' => 'Master Informatique', 'etablissement' => 'Université', 'annee_fin' => 2017, 'date_debut' => '2015-09', 'date_fin' => '2017-06', 'lieu' => 'Metz']],
|
||||
'certifications' => [['id' => 1, 'nom' => 'PSM I']], 'technologies' => [['nom' => 'Java', 'categorie' => 'langage'], ['nom' => 'PostgreSQL', 'categorie' => 'base_de_donnees']],
|
||||
'langues' => [['langue' => 'Français', 'niveau' => 'natif'], ['langue' => 'Anglais', 'niveau' => 'C1', 'ecoute' => null, 'lecture' => null, 'production_orale' => null, 'interaction_orale' => null, 'ecriture' => null]],
|
||||
];
|
||||
$d = donnees_europass($c, ['photo' => true, 'coordonnees' => true], 'fr');
|
||||
verifier('Europass : nom et coordonnées', $d['nom'] === 'Camille EXEMPLE' && count($d['coordonnees']) === 4 && $d['photo'] === 'abc.jpg');
|
||||
verifier('Europass : ni nationalité ni permis de travail', !str_contains(json_encode($d, JSON_UNESCAPED_UNICODE), 'Titre salarié') && !str_contains(json_encode($d), '"MA"'));
|
||||
verifier('Europass : dates au format mm/aaaa', $d['experiences'][0]['dates'] === '03/2020 – EN COURS' && $d['formations'][0]['dates'] === '09/2015 – 06/2017');
|
||||
verifier('Europass : projet, mission, charge', array_column($d['experiences'][0]['details'], 1) === ['Banque X', 'Refonte', 'Développement back-end', 'Java, Spring', '100 %']);
|
||||
verifier('Europass : réalisations sans puces', $d['experiences'][0]['realisations'] === ['Conçoit des API', 'Revue de code']);
|
||||
verifier('Europass : langue sans grille, niveau global', $d['avec_grille'] === false && $d['autres'][0]['niveau'] === 'C1' && $d['maternelles'] === ['FRANÇAIS']);
|
||||
verifier('Europass : compétences groupées', array_keys($d['competences']) === ['Certifications', 'Technique', 'Bases de données']);
|
||||
verifier('Europass : bénévolat et permis', $d['benevolat'] === ['Ateliers de code', 'Maraudes'] && $d['permis'] === ['B', 'A']);
|
||||
$a = donnees_europass($c, ['photo' => true, 'coordonnees' => true, 'anonyme' => true], 'en');
|
||||
verifier('Europass anonymisé : ni nom, ni photo, ni coordonnées', $a['nom'] === 'Profile ITC-0003' && $a['photo'] === null && $a['coordonnees'] === []);
|
||||
verifier('Europass anonymisé : prénom retiré des textes', !str_contains(implode(' ', $a['resume']), 'Camille'));
|
||||
verifier('Europass en anglais : intitulés', $a['libelles']['experience'] === 'Work experience' && $a['libelles']['grille']['ecoute'] === 'Listening');
|
||||
$c['langues'][1] = ['langue' => 'Anglais', 'niveau' => 'C1', 'ecoute' => 'C1', 'lecture' => 'C2', 'production_orale' => 'B2', 'interaction_orale' => 'B2', 'ecriture' => 'C1'];
|
||||
$g = donnees_europass($c, [], 'de');
|
||||
verifier('Europass : grille européenne', $g['avec_grille'] && $g['autres'][0]['grille']['lecture'] === 'C2' && $g['libelles']['langues'] === 'Sprachkenntnisse');
|
||||
verifier('Europass : sans coordonnées ni photo par défaut', $g['coordonnees'] === [] && $g['photo'] === null);
|
||||
|
||||
echo "$ok réussis, $ko échoués\n";
|
||||
exit($ko > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
// Tests du rapprochement, des doublons, de l'appariement du lot et de l'email de sauvegarde : php tests/test_suivi.php
|
||||
declare(strict_types=1);
|
||||
const VIVIER_RACINE = __DIR__ . '/..';
|
||||
const SENIORITES = ['junior' => 'Junior', 'intermediaire' => 'Intermédiaire', 'confirme' => 'Confirmé', 'expert' => 'Expert'];
|
||||
const STATUTS = ['recu' => 'Reçu'];
|
||||
const ETATS_BESOIN = ['ouvert' => 'Ouvert', 'pourvu' => 'Pourvu', 'ferme' => 'Fermé'];
|
||||
require __DIR__ . '/../src/i18n.php';
|
||||
require __DIR__ . '/../src/Recherche.php';
|
||||
require __DIR__ . '/../src/Import.php';
|
||||
require __DIR__ . '/../src/besoins.php';
|
||||
require __DIR__ . '/../src/Lot.php';
|
||||
require __DIR__ . '/../src/Sauvegarde.php';
|
||||
|
||||
$ok = 0; $ko = 0;
|
||||
function verifier(string $nom, bool $cond): void {
|
||||
global $ok, $ko;
|
||||
if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; }
|
||||
}
|
||||
|
||||
// --- Listes saisies
|
||||
verifier('liste : virgules, points-virgules, doublons', liste_saisie(' Java, Spring Boot ; java,, PostgreSQL ') === ['Java', 'Spring Boot', 'PostgreSQL']);
|
||||
verifier('liste vide', liste_saisie(null) === [] && liste_saisie(' , ') === []);
|
||||
|
||||
// --- Rapprochement
|
||||
$profil = static fn (array $technos, string $seniorite = 'confirme', array $plus = []): array => $plus + [
|
||||
'technologies' => $technos,
|
||||
'termes' => array_fill_keys(array_merge(['developpeur', 'java'], array_keys($technos)), true),
|
||||
'seniorite' => $seniorite, 'langues' => ['francais', 'anglais'], 'disponible_le' => null, 'tjm' => null,
|
||||
];
|
||||
$besoin = ['intitule' => 'Développeur Java', 'technologies' => 'Java, Spring Boot, PostgreSQL', 'seniorite_min' => null, 'langues' => null, 'date_debut' => null, 'tjm_max' => null];
|
||||
|
||||
$complet = noter_rapprochement($besoin, $profil(['java' => 9.0, 'spring boot' => 6.0, 'postgresql' => 5.0]));
|
||||
verifier('toutes les technologies, 5 ans et plus : 100', $complet['score'] === 100 && $complet['manquantes'] === [] && count($complet['trouvees']) === 3);
|
||||
|
||||
$partiel = noter_rapprochement($besoin, $profil(['java' => 9.0]));
|
||||
verifier('une technologie sur trois : note plus basse, manquantes listées', $partiel['score'] < $complet['score'] && $partiel['manquantes'] === ['Spring Boot', 'PostgreSQL']);
|
||||
verifier('une sur trois à 5 ans : 55/3 + 45 = 63', $partiel['score'] === 63);
|
||||
|
||||
$debutant = noter_rapprochement($besoin, $profil(['java' => 1.0, 'spring boot' => 1.0, 'postgresql' => 1.0]));
|
||||
verifier('mêmes technologies, moins d\'années : note plus basse', $debutant['score'] < $complet['score'] && $debutant['score'] > $partiel['score']);
|
||||
|
||||
$sansAnnees = noter_rapprochement($besoin, $profil(['java' => null, 'spring boot' => null, 'postgresql' => null]));
|
||||
verifier('années inconnues : 60 % des points de technologie', $sansAnnees['score'] === 78);
|
||||
|
||||
$horsSujet = noter_rapprochement($besoin, ['technologies' => ['cobol' => 20.0], 'termes' => ['cobol' => true], 'seniorite' => 'expert', 'langues' => [], 'disponible_le' => null, 'tjm' => null]);
|
||||
verifier('aucune technologie ni mot de l\'intitulé : profil écarté', $horsSujet['pertinent'] === false);
|
||||
|
||||
// Technologie citée dans une description (index) mais absente de la liste des technologies
|
||||
$parIndex = noter_rapprochement(['intitule' => 'Consultant', 'technologies' => 'Spring Boot'] + $besoin,
|
||||
['technologies' => [], 'termes' => ['spring' => true, 'boot' => true], 'seniorite' => 'junior', 'langues' => [], 'disponible_le' => null, 'tjm' => null]);
|
||||
verifier('technologie retrouvée par l\'index', $parIndex['pertinent'] && count($parIndex['trouvees']) === 1 && $parIndex['trouvees'][0]['annees'] === null);
|
||||
|
||||
$b2 = ['seniorite_min' => 'expert'] + $besoin;
|
||||
$t = ['java' => 9.0, 'spring boot' => 6.0, 'postgresql' => 5.0];
|
||||
verifier('séniorité un niveau en dessous : 4 points sur 10 et alerte', noter_rapprochement($b2, $profil($t, 'confirme'))['score'] === 94 && count(noter_rapprochement($b2, $profil($t, 'confirme'))['alertes']) === 1);
|
||||
verifier('séniorité deux niveaux en dessous : 0 point', noter_rapprochement($b2, $profil($t, 'intermediaire'))['score'] === 90);
|
||||
verifier('séniorité atteinte : pas d\'alerte', noter_rapprochement($b2, $profil($t, 'expert'))['alertes'] === []);
|
||||
|
||||
$b3 = ['langues' => 'Français, Allemand'] + $besoin;
|
||||
$n = noter_rapprochement($b3, $profil($t));
|
||||
verifier('une langue sur deux : 5 points de moins, langue nommée', $n['score'] === 95 && str_contains($n['alertes'][0], 'Allemand'));
|
||||
|
||||
$b4 = ['date_debut' => '2026-11-01', 'tjm_max' => 600] + $besoin;
|
||||
verifier('disponibilité et tarif inconnus : moitié des points', noter_rapprochement($b4, $profil($t))['score'] === 95);
|
||||
verifier('disponible à temps, tarif dans le budget : 100', noter_rapprochement($b4, $profil($t, 'confirme', ['disponible_le' => '2026-10-15', 'tjm' => 550]))['score'] === 100);
|
||||
$tard = noter_rapprochement($b4, $profil($t, 'confirme', ['disponible_le' => '2026-12-01', 'tjm' => 700]));
|
||||
verifier('disponible trop tard, tarif trop haut : 90 et deux alertes', $tard['score'] === 90 && count($tard['alertes']) === 2);
|
||||
verifier('tarif 10 % au-dessus : 2 points', noter_rapprochement($b4, $profil($t, 'confirme', ['disponible_le' => '2026-10-15', 'tjm' => 650]))['score'] === 97);
|
||||
|
||||
$sansTechno = ['technologies' => null] + $besoin;
|
||||
verifier('besoin sans technologie : l\'intitulé porte la note', noter_rapprochement($sansTechno, $profil([]))['score'] === 100);
|
||||
verifier('barème : total 100', array_sum(BAREME_RAPPROCHEMENT) === 100);
|
||||
|
||||
// --- Doublons : clé de téléphone
|
||||
verifier('téléphone : indicatif et espaces ignorés', Import::cleTelephone('+352 621 37 37 95') === Import::cleTelephone('00352-621.373.795') && Import::cleTelephone('+33 6 12 34 56 78') === Import::cleTelephone('06 12 34 56 78'));
|
||||
verifier('téléphone trop court ou absent : pas de comparaison', Import::cleTelephone('12 34') === '' && Import::cleTelephone(null) === '');
|
||||
|
||||
// --- Lot : appariement des fichiers déposés ensemble
|
||||
$dossier = sys_get_temp_dir() . '/vivier-test-' . bin2hex(random_bytes(4));
|
||||
mkdir($dossier);
|
||||
$fichier = static function (string $nom, string $contenu = 'x') use ($dossier): array {
|
||||
file_put_contents("$dossier/$nom", $contenu);
|
||||
return ['tmp' => "$dossier/$nom", 'nom' => $nom, 'ext' => strtolower(pathinfo($nom, PATHINFO_EXTENSION)), 'mime' => 'x', 'taille' => 1];
|
||||
};
|
||||
$json = static fn (string $cv, string $nom): string => (string) json_encode(['identite' => ['prenom' => 'A', 'nom' => $nom], 'source' => ['fichier_origine' => $cv]]);
|
||||
$r = Lot::apparier([
|
||||
$fichier('profil-durand.json', $json('CV Durand.pdf', 'Durand')), $fichier('cv durand.PDF'), $fichier('rapport-durand.md'),
|
||||
$fichier('martin.json', $json('CV_Martin.docx', 'Martin')), $fichier('CV_Martin.docx'),
|
||||
$fichier('seul.json', $json('absent.pdf', 'Seul')), $fichier('orphelin.pdf'), $fichier('casse.json', '{pas du json'),
|
||||
]);
|
||||
verifier('lot : deux paires formées', count($r['paires']) === 2);
|
||||
verifier('lot : CV associé sans tenir compte de la casse', $r['paires'][0]['cv']['nom'] === 'cv durand.PDF' && $r['paires'][1]['cv']['nom'] === 'CV_Martin.docx');
|
||||
verifier('lot : rapport associé par le nom du candidat', ($r['paires'][0]['rapport']['nom'] ?? '') === 'rapport-durand.md' && $r['paires'][1]['rapport'] === null);
|
||||
verifier('lot : CV manquant, fichier orphelin et JSON illisible signalés', count($r['erreurs']) === 3);
|
||||
array_map('unlink', glob("$dossier/*")); rmdir($dossier);
|
||||
|
||||
// --- Sauvegarde : composition de l'email
|
||||
[$sujet, $corps, $entetes] = Sauvegarde::composer('vivier@itclub.lu', 'Vivier : sauvegarde du 11/10/2026', "Bonjour,\r\ntexte accentué.", null, null);
|
||||
verifier('email simple : sujet encodé, texte en UTF-8', str_starts_with($sujet, '=?UTF-8?B?') && base64_decode(substr($sujet, 10, -2)) === 'Vivier : sauvegarde du 11/10/2026'
|
||||
&& str_contains($entetes, 'From: Vivier <vivier@itclub.lu>') && str_contains($entetes, 'text/plain; charset=UTF-8') && str_contains($corps, 'accentué'));
|
||||
$piece = sys_get_temp_dir() . '/vivier-test.zip';
|
||||
file_put_contents($piece, random_bytes(5000));
|
||||
[$sujet, $corps, $entetes] = Sauvegarde::composer('vivier@itclub.lu', 'Essai', 'Texte', $piece, 'vivier-base-2026-10-11-0600.zip');
|
||||
preg_match('/boundary="([^"]+)"/', $entetes, $m);
|
||||
$parties = explode('--' . $m[1], $corps);
|
||||
verifier('email avec pièce : deux parties et une fin', count($parties) === 4 && trim($parties[3]) === '--');
|
||||
verifier('pièce jointe : nom, type, contenu intact', str_contains($parties[2], 'filename="vivier-base-2026-10-11-0600.zip"') && str_contains($parties[2], 'application/zip')
|
||||
&& base64_decode(preg_replace('/\s+/', '', explode("\r\n\r\n", $parties[2], 2)[1])) === file_get_contents($piece));
|
||||
verifier('pièce jointe : lignes de 76 caractères au plus', max(array_map('strlen', explode("\r\n", $parties[2]))) <= 76);
|
||||
unlink($piece);
|
||||
verifier('taille lisible', Sauvegarde::taille(512) === '1 Ko' && Sauvegarde::taille(2621440) === '2,5 Mo');
|
||||
|
||||
echo "$ok réussis, $ko échoués\n";
|
||||
exit($ko > 0 ? 1 : 0);
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// Champs du formulaire d'un besoin client, communs à la création et à la modification.
|
||||
// Attend $saisie : valeurs à afficher.
|
||||
?>
|
||||
<div class="champs">
|
||||
<label><?= e(t('Client')) ?> <input type="text" name="client" maxlength="200" required value="<?= e($saisie['client'] ?? '') ?>"></label>
|
||||
<label><?= e(t('Intitulé du poste')) ?> <input type="text" name="intitule" maxlength="200" required value="<?= e($saisie['intitule'] ?? '') ?>" placeholder="<?= e(t('Développeur Java senior')) ?>"></label>
|
||||
<label class="large"><?= e(t('Technologies demandées, séparées par des virgules')) ?>
|
||||
<input type="text" name="technologies" maxlength="1000" value="<?= e($saisie['technologies'] ?? '') ?>" placeholder="Java, Spring Boot, PostgreSQL">
|
||||
</label>
|
||||
<label><?= e(t('Séniorité minimale')) ?>
|
||||
<select name="seniorite_min">
|
||||
<option value=""><?= e(t('Indifférente')) ?></option>
|
||||
<?php foreach (SENIORITES as $code => $libelle): ?><option value="<?= e($code) ?>"<?= ($saisie['seniorite_min'] ?? '') === $code ? ' selected' : '' ?>><?= e(t($libelle)) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label><?= e(t('Langues demandées, séparées par des virgules')) ?>
|
||||
<input type="text" name="langues" maxlength="300" value="<?= e($saisie['langues'] ?? '') ?>" placeholder="<?= e(t('Français, Anglais')) ?>">
|
||||
</label>
|
||||
<label><?= e(t('Lieu')) ?> <input type="text" name="lieu" maxlength="150" value="<?= e($saisie['lieu'] ?? '') ?>"></label>
|
||||
<label><?= e(t('Télétravail')) ?>
|
||||
<select name="teletravail">
|
||||
<option value=""><?= e(t('Non précisé')) ?></option>
|
||||
<?php foreach (TELETRAVAIL as $code => $libelle): ?><option value="<?= e($code) ?>"<?= ($saisie['teletravail'] ?? '') === $code ? ' selected' : '' ?>><?= e(t($libelle)) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label><?= e(t('Début souhaité')) ?> <input type="date" name="date_debut" value="<?= e($saisie['date_debut'] ?? '') ?>"></label>
|
||||
<label><?= e(t('Budget journalier, en euros')) ?>
|
||||
<input type="text" inputmode="numeric" pattern="[0-9]*" name="tjm_max" maxlength="5" value="<?= e($saisie['tjm_max'] ?? '') ?>">
|
||||
</label>
|
||||
<label class="large"><?= e(t('Description')) ?>
|
||||
<textarea name="description" rows="4" maxlength="10000"><?= e($saisie['description'] ?? '') ?></textarea>
|
||||
</label>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
$u = utilisateur();
|
||||
$admin = ($u['role'] ?? '') === 'admin';
|
||||
// Sans compte connecté, seul le guide Sécurité est lisible : il s'ouvre par sa propre adresse
|
||||
$lien = static fn (string $g, string $l): string => $u ? url('aide', ['guide' => $g, 'langue' => $l]) : url('securite', ['langue' => $l]);
|
||||
$guides = array_filter(DOCUMENTS_AIDE, static fn ($code) => $code !== 'administration' || $admin, ARRAY_FILTER_USE_KEY);
|
||||
?>
|
||||
<?php if (!$u): ?>
|
||||
<p class="fil"><a href="<?= e(url('connexion')) ?>"><?= e(t('Retour à la connexion')) ?></a></p>
|
||||
<?php endif; ?>
|
||||
<div class="barre-titre aide-tete">
|
||||
<h1><?= e(t(DOCUMENTS_AIDE[$guide])) ?></h1>
|
||||
<div class="aide-outils">
|
||||
<span class="versions" role="group" aria-label="<?= e(t('Langue du guide')) ?>">
|
||||
<?php foreach (LANGUES_INTERFACE as $code => $nom): ?>
|
||||
<a href="<?= e($lien($guide, $code)) ?>" lang="<?= e($code) ?>" title="<?= e($nom) ?>"<?= $code === $aide['langue'] ? ' aria-current="true"' : '' ?>><?= e(strtoupper($code)) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
<button type="button" class="secondaire" data-imprimer><?= e(t('Imprimer ou enregistrer en PDF')) ?></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aide-page">
|
||||
<nav class="aide-sommaire" aria-label="<?= e(t('Sommaire')) ?>">
|
||||
<?php if ($u): ?>
|
||||
<p class="aide-guides">
|
||||
<?php foreach ($guides as $code => $libelle): ?>
|
||||
<a href="<?= e($lien($code, $aide['langue'])) ?>"<?= $code === $guide ? ' aria-current="page"' : '' ?>><?= e(t($libelle)) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<ol>
|
||||
<?php foreach ($aide['chapitres'] as $ch): ?>
|
||||
<li><a href="#<?= e($ch['id']) ?>"><?= e($ch['titre']) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</nav>
|
||||
<article class="carte rapport aide-texte" lang="<?= e($aide['langue']) ?>">
|
||||
<?= $aide['html'] ?>
|
||||
</article>
|
||||
</div>
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
$ouvert = $b['statut'] === 'ouvert';
|
||||
$actives = array_filter($b['propositions'], static fn ($p) => $p['etat'] !== 'ecarte');
|
||||
?>
|
||||
<p class="fil"><a href="<?= e(url('besoins')) ?>"><?= e(t('Besoins clients')) ?></a> / <?= e($b['client']) ?></p>
|
||||
|
||||
<div class="entete-profil">
|
||||
<div>
|
||||
<h1><?= e($b['intitule']) ?></h1>
|
||||
<p class="sous-titre">
|
||||
<strong><?= e($b['client']) ?></strong>
|
||||
<span class="badge badge-besoin-<?= e($b['statut']) ?>"><?= e(t(ETATS_BESOIN[$b['statut']])) ?></span>
|
||||
<span><?= e(t('créé par %s le %s', $b['nom_affiche'], date_fr($b['cree_le']))) ?></span>
|
||||
</p>
|
||||
</div>
|
||||
<?php if (peut_ecrire()): ?>
|
||||
<form method="post" action="<?= e(url('besoin', ['id' => $b['id']])) ?>" class="statut-form">
|
||||
<?= champ_csrf() ?>
|
||||
<label><?= e(t('Statut du besoin')) ?>
|
||||
<select name="statut">
|
||||
<?php foreach (ETATS_BESOIN as $code => $libelle): ?><option value="<?= e($code) ?>"<?= $b['statut'] === $code ? ' selected' : '' ?>><?= e(t($libelle)) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" name="action" value="statut" class="secondaire"><?= e(t('Mettre à jour')) ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($erreur !== null): ?><p class="flash flash-erreur" role="alert"><?= e($erreur) ?></p><?php endif; ?>
|
||||
|
||||
<div class="deux-colonnes">
|
||||
<div>
|
||||
<section class="carte" id="propositions">
|
||||
<h2><?= e(t('Profils proposés')) ?> <small><?= count($actives) ?></small></h2>
|
||||
<?php if (!$b['propositions']): ?>
|
||||
<p class="vide"><?= e(t('Aucun profil proposé. Choisissez-en dans la liste ci-dessous, ou depuis la fiche d\'un profil.')) ?></p>
|
||||
<?php else: ?>
|
||||
<ul class="simple agenda">
|
||||
<?php foreach ($b['propositions'] as $p): ?>
|
||||
<li class="<?= $p['etat'] === 'ecarte' ? 'faite' : '' ?>">
|
||||
<?= avatar($p) ?>
|
||||
<div>
|
||||
<a href="<?= e(url('profil', ['id' => $p['id']])) ?>"><?= e($p['prenom'] . ' ' . $p['nom']) ?></a>
|
||||
<span class="badge badge-proposition-<?= e($p['etat']) ?>"><?= e(t(ETATS_PROPOSITION[$p['etat']])) ?></span>
|
||||
<p><?= e(implode(' · ', array_filter([
|
||||
$p['titre'], t(SENIORITES[$p['seniorite']]), annees($p['annees_experience']), t(STATUTS[$p['statut']]),
|
||||
$p['archive_le'] ? t('Archivé') : null, $p['tjm'] ? t('TJM %s', euros($p['tjm'])) : null,
|
||||
]))) ?></p>
|
||||
<?php if ($p['note']): ?><p><?= e($p['note']) ?></p><?php endif; ?>
|
||||
<small><?= e(t('ajouté par %s le %s', $p['nom_affiche'], date_fr($p['ajoute_le']))) ?></small>
|
||||
<?php if (peut_ecrire()): ?>
|
||||
<details>
|
||||
<summary><?= e(t('Mettre à jour')) ?></summary>
|
||||
<form method="post" action="<?= e(url('besoin', ['id' => $b['id']])) ?>" class="pile">
|
||||
<?= champ_csrf() ?>
|
||||
<input type="hidden" name="candidat_id" value="<?= (int) $p['id'] ?>">
|
||||
<label><?= e(t('État')) ?>
|
||||
<select name="etat">
|
||||
<?php foreach (ETATS_PROPOSITION as $code => $libelle): ?><option value="<?= e($code) ?>"<?= $p['etat'] === $code ? ' selected' : '' ?>><?= e(t($libelle)) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label><?= e(t('Note')) ?> <input type="text" name="note" maxlength="500" value="<?= e($p['note'] ?? '') ?>"></label>
|
||||
<div class="actions">
|
||||
<button type="submit" name="action" value="retirer" class="lien"><?= e(t('Retirer de ce besoin')) ?></button>
|
||||
<button type="submit" name="action" value="etat" class="secondaire"><?= e(t('Enregistrer')) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if ($ouvert): ?>
|
||||
<section class="carte" id="rapprochement">
|
||||
<h2><?= e(t('Profils du vivier qui répondent au besoin')) ?></h2>
|
||||
<?php if (!$suggestions): ?>
|
||||
<p class="vide"><?= e(t('Aucun profil ne porte les technologies ni les mots de l\'intitulé. Élargissez la demande, ou importez de nouveaux CV.')) ?></p>
|
||||
<?php else: ?>
|
||||
<ul class="simple agenda rapprochement">
|
||||
<?php foreach ($suggestions as $r): ?>
|
||||
<li>
|
||||
<?= avatar($r) ?>
|
||||
<div>
|
||||
<a href="<?= e(url('profil', ['id' => $r['id']])) ?>"><?= e($r['prenom'] . ' ' . $r['nom']) ?></a>
|
||||
<span class="note-rapprochement" title="<?= e(t('Note de rapprochement sur 100')) ?>"><?= (int) $r['score'] ?><small>/100</small></span>
|
||||
<p><?= e(implode(' · ', array_filter([
|
||||
$r['titre'], t(SENIORITES[$r['seniorite']]), annees($r['annees_experience']), t(STATUTS[$r['statut']]),
|
||||
libelle_disponibilite($r) ?: null,
|
||||
$r['tjm'] ? t('TJM %s', euros($r['tjm'])) : null,
|
||||
]))) ?></p>
|
||||
<?php if ($r['trouvees'] || $r['manquantes']): ?>
|
||||
<p class="technos">
|
||||
<?php foreach ($r['trouvees'] as $t): ?><span class="trouve"><?= e($t['nom']) ?><?= $t['annees'] !== null ? ' ' . e(annees($t['annees'])) : '' ?></span><?php endforeach; ?>
|
||||
<?php foreach ($r['manquantes'] as $t): ?><span class="absent"><?= e(t('%s absent', $t)) ?></span><?php endforeach; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($r['alertes'] as $a): ?><small class="retard"><?= e($a) ?></small><?php endforeach; ?>
|
||||
<?php if (peut_ecrire()): ?>
|
||||
<form method="post" action="<?= e(url('besoin', ['id' => $b['id']])) ?>" class="actions-ligne">
|
||||
<?= champ_csrf() ?>
|
||||
<input type="hidden" name="candidat_id" value="<?= (int) $r['id'] ?>">
|
||||
<button type="submit" name="action" value="proposer" class="secondaire"><?= e(t('Proposer')) ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<p class="aide"><?= e(t('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.')) ?></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<section class="carte">
|
||||
<h2><?= e(t('Demande')) ?></h2>
|
||||
<dl class="grille serre">
|
||||
<dt><?= e(t('Séniorité')) ?></dt><dd><?= $b['seniorite_min'] ? e(t('%s au moins', t(SENIORITES[$b['seniorite_min']]))) : '<em>' . e(t('Indifférente')) . '</em>' ?></dd>
|
||||
<dt><?= e(t('Langues')) ?></dt><dd><?= e($b['langues']) ?: '<em>' . e(t('non renseigné')) . '</em>' ?></dd>
|
||||
<dt><?= e(t('Lieu')) ?></dt><dd><?= e($b['lieu']) ?: '<em>' . e(t('non renseigné')) . '</em>' ?></dd>
|
||||
<dt><?= e(t('Télétravail')) ?></dt><dd><?= $b['teletravail'] ? e(t(TELETRAVAIL[$b['teletravail']])) : '<em>' . e(t('non renseigné')) . '</em>' ?></dd>
|
||||
<dt><?= e(t('Début')) ?></dt><dd><?= e(date_fr($b['date_debut'])) ?: '<em>' . e(t('non renseigné')) . '</em>' ?></dd>
|
||||
<dt><?= e(t('Budget')) ?></dt><dd><?= $b['tjm_max'] ? e(t('%s par jour', euros($b['tjm_max']))) : '<em>' . e(t('non renseigné')) . '</em>' ?></dd>
|
||||
</dl>
|
||||
<?php if ($b['technologies']): ?>
|
||||
<p class="technos"><?php foreach (liste_saisie($b['technologies']) as $t): ?><span><?= e($t) ?></span><?php endforeach; ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($b['description']): ?><p class="description-besoin"><?= nl2br(e($b['description'])) ?></p><?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if (peut_ecrire()): ?>
|
||||
<section class="carte">
|
||||
<h2><?= e(t('Gestion')) ?></h2>
|
||||
<details<?= $erreur !== null ? ' open' : '' ?>>
|
||||
<summary><?= e(t('Modifier le besoin')) ?></summary>
|
||||
<form method="post" action="<?= e(url('besoin', ['id' => $b['id']])) ?>" class="pile besoin-etroit">
|
||||
<?= champ_csrf() ?>
|
||||
<?php require __DIR__ . '/_besoin_champs.php'; ?>
|
||||
<div class="actions"><button type="submit" name="action" value="modifier" class="secondaire"><?= e(t('Enregistrer')) ?></button></div>
|
||||
</form>
|
||||
</details>
|
||||
<form method="post" action="<?= e(url('besoin', ['id' => $b['id']])) ?>" class="danger"
|
||||
data-confirmer="<?= e(t('Supprimer ce besoin et la liste des profils proposés ? Les profils eux-mêmes sont conservés.')) ?>">
|
||||
<?= champ_csrf() ?>
|
||||
<button type="submit" name="action" value="supprimer" class="dangereux"><?= e(t('Supprimer le besoin')) ?></button>
|
||||
</form>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="barre-titre">
|
||||
<h1><?= e(t('Besoins clients')) ?> <small><?= e(tn(count($liste), '%d besoin', '%d besoins')) ?></small></h1>
|
||||
<form class="filtres" method="get" action="index.php" data-auto>
|
||||
<input type="hidden" name="p" value="besoins">
|
||||
<label><?= e(t('Statut')) ?>
|
||||
<select name="statut">
|
||||
<option value=""><?= e(t('Tous')) ?></option>
|
||||
<?php foreach (ETATS_BESOIN as $code => $libelle): ?><option value="<?= e($code) ?>"<?= $statut === $code ? ' selected' : '' ?>><?= e(t($libelle)) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="secondaire" data-auto-masquer><?= e(t('Filtrer')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="aide"><?= e(t('Un besoin décrit une mission à pourvoir chez un client. Vivier propose les profils qui y répondent le mieux.')) ?></p>
|
||||
|
||||
<?php if (peut_ecrire()): ?>
|
||||
<section class="carte">
|
||||
<details<?= $erreur !== null || !$liste ? ' open' : '' ?>>
|
||||
<summary><?= e(t('Nouveau besoin')) ?></summary>
|
||||
<?php if ($erreur !== null): ?><p class="flash flash-erreur" role="alert"><?= e($erreur) ?></p><?php endif; ?>
|
||||
<form method="post" action="<?= e(url('besoins')) ?>" class="pile">
|
||||
<?= champ_csrf() ?>
|
||||
<?php require __DIR__ . '/_besoin_champs.php'; ?>
|
||||
<div class="actions"><button type="submit"><?= e(t('Enregistrer le besoin')) ?></button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$liste): ?>
|
||||
<section class="carte"><p class="vide"><?= e($statut !== '' ? t('Aucun besoin avec ce statut.') : t('Aucun besoin enregistré.')) ?></p></section>
|
||||
<?php else: ?>
|
||||
<ul class="resultats">
|
||||
<?php foreach ($liste as $b): ?>
|
||||
<li class="carte<?= $b['statut'] !== 'ouvert' ? ' inactif' : '' ?>">
|
||||
<div class="resultat-tete">
|
||||
<a class="nom" href="<?= e(url('besoin', ['id' => $b['id']])) ?>"><?= e($b['intitule']) ?></a>
|
||||
<span class="etat"><span class="badge badge-besoin-<?= e($b['statut']) ?>"><?= e(t(ETATS_BESOIN[$b['statut']])) ?></span></span>
|
||||
</div>
|
||||
<p class="sous-titre">
|
||||
<strong><?= e($b['client']) ?></strong>
|
||||
<?php if ($b['seniorite_min']): ?><span class="badge badge-<?= e($b['seniorite_min']) ?>"><?= e(t(SENIORITES[$b['seniorite_min']])) ?></span><?php endif; ?>
|
||||
<?php if ($b['lieu']): ?><span><?= e($b['lieu']) ?></span><?php endif; ?>
|
||||
<?php if ($b['date_debut']): ?><span><?= e(t('début le %s', date_fr($b['date_debut']))) ?></span><?php endif; ?>
|
||||
<span><?= e(tn((int) $b['proposes'], '%d profil proposé', '%d profils proposés')) ?></span>
|
||||
</p>
|
||||
<?php if ($b['technologies']): ?>
|
||||
<p class="technos"><?php foreach (liste_saisie($b['technologies']) as $t): ?><span><?= e($t) ?></span><?php endforeach; ?></p>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
@@ -0,0 +1,13 @@
|
||||
<section class="carte moyen">
|
||||
<h1><?= e(t('Vos codes de secours')) ?></h1>
|
||||
<p><?= e(t('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.')) ?></p>
|
||||
<p class="flash flash-info"><?= e(t('Ils ne seront plus jamais affichés. Les codes précédents ne marchent plus.')) ?></p>
|
||||
<ol class="codes-secours">
|
||||
<?php foreach ($codes as $code): ?><li><code><?= e($code) ?></code></li><?php endforeach; ?>
|
||||
</ol>
|
||||
<div class="actions gauche">
|
||||
<button type="button" class="secondaire" data-copier="<?= e(implode("\n", $codes)) ?>" data-copie="<?= e(t('Copiés')) ?>"><?= e(t('Copier les codes')) ?></button>
|
||||
<button type="button" class="secondaire" data-imprimer><?= e(t('Imprimer')) ?></button>
|
||||
<a class="bouton" href="<?= e(url('kanban')) ?>"><?= e(t('J\'ai gardé mes codes, continuer')) ?></a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php $restants = codes_secours_restants($compte); ?>
|
||||
<div class="barre-titre"><h1><?= e(t('Mon compte')) ?></h1></div>
|
||||
|
||||
<div class="deux-egales">
|
||||
<section class="carte">
|
||||
<h2><?= e(t('Identité')) ?></h2>
|
||||
<dl class="grille serre">
|
||||
<dt><?= e(t('Nom')) ?></dt><dd><?= e($moi['nom_affiche']) ?></dd>
|
||||
<dt><?= e(t('Identifiant')) ?></dt><dd><?= e($moi['identifiant']) ?></dd>
|
||||
<dt><?= e(t('Rôle')) ?></dt><dd><?= e($moi['role'] === 'admin' ? t('Gestionnaire des comptes') : t('Utilisateur')) ?></dd>
|
||||
</dl>
|
||||
<p class="aide"><?php if ($moi['role'] === 'admin'): ?><?= e(t('Votre nom et votre identifiant se changent dans Gestion, Comptes.')) ?><?php else: ?><?= e(t('Pour changer de nom ou d\'identifiant, adressez-vous au gestionnaire de Vivier.')) ?><?php endif; ?></p>
|
||||
</section>
|
||||
|
||||
<section class="carte" id="double-authentification">
|
||||
<h2><?= e(t('Double authentification')) ?></h2>
|
||||
<dl class="grille serre">
|
||||
<dt><?= e(t('État')) ?></dt><dd><?= e(t('Activée le %s', date_fr($compte['totp_active_le']))) ?></dd>
|
||||
<dt><?= e(t('Codes de secours')) ?></dt><dd class="<?= $restants <= 3 ? 'retard' : '' ?>"><?= e(tn($restants, '%d restant', '%d restants')) ?></dd>
|
||||
</dl>
|
||||
<details>
|
||||
<summary><?= e(t('Générer de nouveaux codes de secours')) ?></summary>
|
||||
<p class="aide"><?= e(t('Les dix nouveaux codes remplacent les anciens, qui ne marcheront plus.')) ?></p>
|
||||
<form method="post" action="<?= e(url('compte')) ?>" class="pile">
|
||||
<?= champ_csrf() ?>
|
||||
<input type="hidden" name="action" value="codes">
|
||||
<label><?= e(t('Votre mot de passe')) ?> <input type="password" name="mot_de_passe" required autocomplete="current-password"></label>
|
||||
<div class="actions gauche"><button type="submit" class="secondaire"><?= e(t('Générer les codes')) ?></button></div>
|
||||
</form>
|
||||
</details>
|
||||
<p><a href="<?= e(url('double_authentification')) ?>"><?= e(t('Configurer une autre application (nouveau téléphone)')) ?></a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="deux-egales">
|
||||
<section class="carte">
|
||||
<h2><?= e(t('Changer de mot de passe')) ?></h2>
|
||||
<form method="post" action="<?= e(url('compte')) ?>" class="pile">
|
||||
<?= champ_csrf() ?>
|
||||
<input type="hidden" name="action" value="mot_de_passe">
|
||||
<label><?= e(t('Mot de passe actuel')) ?>
|
||||
<input type="password" name="actuel" required autocomplete="current-password">
|
||||
</label>
|
||||
<label><?= e(t('Nouveau mot de passe, %d caractères au moins', LONGUEUR_MOT_DE_PASSE)) ?>
|
||||
<input type="password" name="nouveau" required minlength="<?= LONGUEUR_MOT_DE_PASSE ?>" maxlength="<?= LONGUEUR_MAX_MOT_DE_PASSE ?>" autocomplete="new-password">
|
||||
</label>
|
||||
<label><?= e(t('Confirmation')) ?>
|
||||
<input type="password" name="confirmation" required minlength="<?= LONGUEUR_MOT_DE_PASSE ?>" maxlength="<?= LONGUEUR_MAX_MOT_DE_PASSE ?>" autocomplete="new-password">
|
||||
</label>
|
||||
<p class="aide"><?= e(t('Vivier refuse les mots de passe connus dans des fuites de données. Vos autres sessions se fermeront.')) ?></p>
|
||||
<div class="actions gauche"><button type="submit"><?= e(t('Changer le mot de passe')) ?></button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="carte" id="sessions">
|
||||
<h2><?= e(t('Sessions et connexions')) ?></h2>
|
||||
<p class="aide"><?= e(t('Une session se ferme après 2 heures sans activité, et au plus tard 12 heures après la connexion.')) ?></p>
|
||||
<form method="post" action="<?= e(url('compte')) ?>" class="bloc-action">
|
||||
<?= champ_csrf() ?>
|
||||
<input type="hidden" name="action" value="sessions">
|
||||
<button type="submit" class="secondaire"><?= e(t('Déconnecter mes autres sessions')) ?></button>
|
||||
</form>
|
||||
<p class="aide"><?= e(t('Utile si vous êtes resté connecté sur un autre ordinateur ou un téléphone perdu.')) ?></p>
|
||||
<?php if ($derniere): ?>
|
||||
<h3 class="intertitre"><?= e(t('Vos dernières connexions')) ?></h3>
|
||||
<ul class="simple connexions-courtes">
|
||||
<?php foreach ($derniere as $l): ?>
|
||||
<li><?= e(date_fr($l['moment'], true)) ?> <small><?= e(navigateur_lisible($l['navigateur'])) ?> · <?= e($l['ip']) ?></small></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<p class="aide"><?= e(t('Une connexion que vous ne reconnaissez pas ? Changez votre mot de passe et prévenez le gestionnaire de Vivier.')) ?></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="porte">
|
||||
<img src="assets/logo-itclub-or.png" alt="<?= e(t('IT Club')) ?>" width="224" height="70">
|
||||
<p class="porte-nom"><?= e(config('nom_app')) ?></p>
|
||||
<p class="porte-role"><?= e(t('Le suivi des CV d\'IT Club')) ?></p>
|
||||
<form method="post" action="<?= e(url('connexion')) ?>" class="pile">
|
||||
<?= champ_csrf() ?>
|
||||
<?php if ($erreur): ?><p class="flash flash-erreur" role="alert"><?= e($erreur) ?></p><?php endif; ?>
|
||||
<?php if ($info): ?><p class="flash flash-info" role="status"><?= e($info) ?></p><?php endif; ?>
|
||||
<label><?= e(t('Identifiant')) ?>
|
||||
<input type="text" name="identifiant" required autofocus autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="190">
|
||||
</label>
|
||||
<label><?= e(t('Mot de passe')) ?>
|
||||
<input type="password" name="mot_de_passe" required autocomplete="current-password" maxlength="<?= LONGUEUR_MAX_MOT_DE_PASSE ?>">
|
||||
</label>
|
||||
<button type="submit"><?= e(t('Continuer')) ?></button>
|
||||
</form>
|
||||
<p class="porte-note"><?= e(t('Étape suivante : le code de votre application d\'authentification.')) ?></p>
|
||||
<p class="porte-lien">
|
||||
<a href="<?= e(url('inscription')) ?>"><?= e(t('Pas encore de compte ?')) ?></a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<a href="<?= e(url('securite')) ?>"><?= e(t('Comment Vivier protège vos accès')) ?></a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="barre-titre">
|
||||
<h1><?= e(t('Connexions')) ?> <small><?= e(tn(count($lignes), '%d ligne', '%d lignes')) ?></small></h1>
|
||||
<form class="filtres" method="get" action="index.php" data-auto>
|
||||
<input type="hidden" name="p" value="connexions">
|
||||
<label><?= e(t('Compte')) ?>
|
||||
<select name="utilisateur">
|
||||
<option value="0"><?= e(t('Tous')) ?></option>
|
||||
<?php foreach ($comptes as $c): ?><option value="<?= (int) $c['id'] ?>"<?= $qui === (int) $c['id'] ? ' selected' : '' ?>><?= e($c['nom_affiche']) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label><?= e(t('Résultat')) ?>
|
||||
<select name="resultat">
|
||||
<option value=""><?= e(t('Tous')) ?></option>
|
||||
<option value="echecs"<?= $resultat === 'echecs' ? ' selected' : '' ?>><?= e(t('Échecs seulement')) ?></option>
|
||||
<?php foreach (RESULTATS_CONNEXION as $code => $libelle): ?><option value="<?= e($code) ?>"<?= $resultat === $code ? ' selected' : '' ?>><?= e(t($libelle)) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" data-auto-masquer><?= e(t('Filtrer')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="aide"><?= e(t('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.', CONNEXIONS_MOIS)) ?></p>
|
||||
<?php if (!$lignes): ?>
|
||||
<section class="carte"><p class="vide"><?= e(t('Aucune connexion enregistrée.')) ?></p></section>
|
||||
<?php else: ?>
|
||||
<section class="carte defile">
|
||||
<table>
|
||||
<thead><tr><th><?= e(t('Date')) ?></th><th><?= e(t('Compte')) ?></th><th><?= e(t('Résultat')) ?></th><th><?= e(t('Adresse IP')) ?></th><th><?= e(t('Navigateur')) ?></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($lignes as $l): ?>
|
||||
<tr>
|
||||
<td><?= e(date_fr($l['moment'], true)) ?></td>
|
||||
<td><?= e($l['nom_affiche'] ?? $l['identifiant']) ?><?php if ($l['nom_affiche'] === null): ?> <small><?= e(t('identifiant inconnu')) ?></small><?php endif; ?></td>
|
||||
<td><span class="<?= $l['resultat'] === 'reussie' ? '' : 'retard' ?>"><?= e(t(RESULTATS_CONNEXION[$l['resultat']] ?? $l['resultat'])) ?></span></td>
|
||||
<td><?= e($l['ip']) ?></td>
|
||||
<td><?= e(navigateur_lisible($l['navigateur'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||