diff --git a/@apps/backend/src/seeders/curriculumInjector.seeder.ts b/@apps/backend/src/seeders/curriculumInjector.seeder.ts index 910e960..f58b686 100644 --- a/@apps/backend/src/seeders/curriculumInjector.seeder.ts +++ b/@apps/backend/src/seeders/curriculumInjector.seeder.ts @@ -43,8 +43,16 @@ function createSection( template: SectionTemplatesEntityType, title: string, position: number, + isActive: boolean, ) { - return em.create("Sections", { id, curriculum, template, title, position }); + return em.create("Sections", { + id, + curriculum, + template, + title, + position, + isActive, + }); } function createItem( @@ -77,14 +85,23 @@ export default function curriculumInjectorSeeder(em: EntityManager) { tPerso!, "Informations personnelles", 0, + true, ); - const c1Profil = createSection(em, "e2e-c1-s2-profil", cv1, tProfil!, "Profil", 1); - const c1Exp = createSection(em, "e2e-c1-s3-exp", cv1, tExperiences!, "Expériences", 2); - const c1Form = createSection(em, "e2e-c1-s4-form", cv1, tFormation!, "Formations", 3); - const c1Comp = createSection(em, "e2e-c1-s5-comp", cv1, tCompetences!, "Compétences", 4); - const c1Langue = createSection(em, "e2e-c1-s6-langue", cv1, tLangue!, "Langues", 5); - const c1Centres = createSection(em, "e2e-c1-s7-centres", cv1, tCentres!, "Centres d'intérêt", 6); - const c1Refs = createSection(em, "e2e-c1-s8-refs", cv1, tReferences!, "Références", 7); + const c1Profil = createSection(em, "e2e-c1-s2-profil", cv1, tProfil!, "Profil", 1, true); + const c1Exp = createSection(em, "e2e-c1-s3-exp", cv1, tExperiences!, "Expériences", 2, true); + const c1Form = createSection(em, "e2e-c1-s4-form", cv1, tFormation!, "Formations", 3, true); + const c1Comp = createSection(em, "e2e-c1-s5-comp", cv1, tCompetences!, "Compétences", 4, true); + const c1Langue = createSection(em, "e2e-c1-s6-langue", cv1, tLangue!, "Langues", 5, true); + const c1Centres = createSection( + em, + "e2e-c1-s7-centres", + cv1, + tCentres!, + "Centres d'intérêt", + 6, + true, + ); + const c1Refs = createSection(em, "e2e-c1-s8-refs", cv1, tReferences!, "Références", 7, true); createItem(em, "e2e-c1-s1-perso-i1", c1Perso, 0, { firstName: "Amaury", @@ -180,144 +197,4 @@ export default function curriculumInjectorSeeder(em: EntityManager) { phone: "0612345678", city: "Lyon", }); - - // ── CV 2 : CV junior (moins de sections) ───────────── - const cv2 = em.create(CurriculumEntity, { - id: "e2e-c2", - userId: "e2e-login-user", - title: "CV Junior - Sophie Martin", - createdAt: new Date("2025-09-15"), - updatedAt: new Date("2025-09-15"), - }) as CurriculumEntityType; - - const s2Perso = createSection( - em, - "e2e-c2-s1-perso", - cv2, - tPerso!, - "Informations personnelles", - 0, - ); - const s2Profil = createSection(em, "e2e-c2-s2-profil", cv2, tProfil!, "Profil", 1); - const s2Form = createSection(em, "e2e-c2-s3-form", cv2, tFormation!, "Formations", 2); - const s2Comp = createSection(em, "e2e-c2-s4-comp", cv2, tCompetences!, "Compétences", 3); - const s2Langue = createSection(em, "e2e-c2-s5-langue", cv2, tLangue!, "Langues", 4); - - createItem(em, "e2e-c2-s1-perso-i1", s2Perso, 0, { - firstName: "Sophie", - lastName: "Martin", - email: "sophie.martin@outlook.com", - phone: "0698765432", - address: "45 avenue Foch, 69000 Lyon", - }); - createItem(em, "e2e-c2-s2-profil-i1", s2Profil, 0, { - description: - "Étudiante en master informatique à la recherche d'une alternance. Motivée, rigoureuse et passionnée par le développement web.", - }); - createItem(em, "e2e-c2-s3-form-i1", s2Form, 0, { - school: "INSA Lyon", - degree: "Master Ingénierie Logicielle", - field: "Informatique", - startDate: "2023-09-01", - endDate: "2025-06-30", - description: "Formation en alternance, spécialisation web et cloud.", - }); - createItem(em, "e2e-c2-s3-form-i2", s2Form, 1, { - school: "IUT de Lyon", - degree: "BUT Informatique", - field: "Informatique", - startDate: "2020-09-01", - endDate: "2023-06-30", - description: "Formation pratique en développement logiciel.", - }); - createItem(em, "e2e-c2-s4-comp-i1", s2Comp, 0, { competence: "React", level: "Intermédiaire" }); - createItem(em, "e2e-c2-s4-comp-i2", s2Comp, 1, { competence: "Python", level: "Intermédiaire" }); - createItem(em, "e2e-c2-s5-langue-i1", s2Langue, 0, { language: "Français", level: "Natif" }); - createItem(em, "e2e-c2-s5-langue-i2", s2Langue, 1, { language: "Anglais", level: "B2" }); - createItem(em, "e2e-c2-s5-langue-i3", s2Langue, 2, { language: "Espagnol", level: "B1" }); - - // ── CV 3 : CV senior (expériences longues) ──────────── - const cv3 = em.create(CurriculumEntity, { - id: "e2e-c3", - userId: "e2e-login-user", - title: "CV Senior - Marc Leblanc", - createdAt: new Date("2025-11-01"), - updatedAt: new Date("2025-11-01"), - }) as CurriculumEntityType; - const s3Perso = createSection( - em, - "e2e-c3-s1-perso", - cv3, - tPerso!, - "Informations personnelles", - 1, - ); - const s3Profil = createSection(em, "e2e-c3-s2-profil", cv3, tProfil!, "Profil", 2); - const s3Exp = createSection(em, "e2e-c3-s3-exp", cv3, tExperiences!, "Expériences", 3); - const s3Form = createSection(em, "e2e-c3-s4-form", cv3, tFormation!, "Formations", 4); - const s3Comp = createSection(em, "e2e-c3-s5-comp", cv3, tCompetences!, "Compétences", 5); - const s3Refs = createSection(em, "e2e-c3-s6-refs", cv3, tReferences!, "Références", 6); - - createItem(em, "e2e-c3-s1-perso-i1", s3Perso, 0, { - firstName: "Marc", - lastName: "Leblanc", - email: "marc.leblanc@gmail.com", - phone: "0677889900", - address: "8 place Bellecour, 69002 Lyon", - }); - createItem(em, "e2e-c3-s2-profil-i1", s3Profil, 0, { - description: - "Architecte logiciel avec 15 ans d'expérience. Expert en conception de systèmes distribués, microservices et cloud AWS. Passionné par les bonnes pratiques et le mentoring.", - }); - createItem(em, "e2e-c3-s3-exp-i1", s3Exp, 0, { - employer: "BNP Paribas", - position: "Architecte Solutions", - city: "Paris", - startDate: "2019-01-01", - endDate: "", - description: - "Conception et supervision de l'architecture SI pour les services de paiement en ligne. Équipe de 12 développeurs.", - }); - createItem(em, "e2e-c3-s3-exp-i2", s3Exp, 1, { - employer: "Capgemini", - position: "Tech Lead Java", - city: "Lyon", - startDate: "2014-04-01", - endDate: "2018-12-31", - description: - "Lead technique sur des projets grands comptes. Mise en place CI/CD et pratiques DevOps.", - }); - createItem(em, "e2e-c3-s3-exp-i3", s3Exp, 2, { - employer: "Sopra Steria", - position: "Développeur Backend", - city: "Lyon", - startDate: "2010-09-01", - endDate: "2014-03-31", - description: "Développement d'applications Java EE pour le secteur bancaire.", - }); - createItem(em, "e2e-c3-s4-form-i1", s3Form, 0, { - school: "École Centrale Paris", - degree: "Diplôme d'ingénieur", - field: "Informatique & Systèmes", - startDate: "2007-09-01", - endDate: "2010-06-30", - description: "Grande école d'ingénieurs, majeure systèmes d'information.", - }); - createItem(em, "e2e-c3-s5-comp-i1", s3Comp, 0, { competence: "Java / Spring", level: "Expert" }); - createItem(em, "e2e-c3-s5-comp-i2", s3Comp, 1, { competence: "AWS", level: "Expert" }); - createItem(em, "e2e-c3-s5-comp-i3", s3Comp, 2, { competence: "Kubernetes", level: "Avancé" }); - createItem(em, "e2e-c3-s6-refs-i1", s3Refs, 0, { - name: "Claire Morin", - entreprise: "BNP Paribas", - email: "c.morin@bnp.fr", - phone: "0145678901", - city: "Paris", - }); - createItem(em, "e2e-c3-s6-refs-i2", s3Refs, 1, { - name: "Thomas Renard", - entreprise: "Capgemini", - email: "t.renard@capgemini.com", - phone: "0472345678", - city: "Lyon", - }); } diff --git a/@apps/backend/src/templates/template1/base.html b/@apps/backend/src/templates/template1/base.html new file mode 100644 index 0000000..eaa4a55 --- /dev/null +++ b/@apps/backend/src/templates/template1/base.html @@ -0,0 +1,17 @@ + + + + + + + + +
+
{{#each leftSections}} {{{this.html}}} {{/each}}
+ +
{{#each rightSections}} {{{this.html}}} {{/each}}
+
+ + diff --git "a/@apps/backend/src/templates/template1/sections/centres-d'int\303\251r\303\252t.html" "b/@apps/backend/src/templates/template1/sections/centres-d'int\303\251r\303\252t.html" new file mode 100644 index 0000000..73605e6 --- /dev/null +++ "b/@apps/backend/src/templates/template1/sections/centres-d'int\303\251r\303\252t.html" @@ -0,0 +1,8 @@ +
+

{{title}}

+
    + {{#each items}} +
  • {{this.name}}
  • + {{/each}} +
+
diff --git "a/@apps/backend/src/templates/template1/sections/comp\303\251tences.html" "b/@apps/backend/src/templates/template1/sections/comp\303\251tences.html" new file mode 100644 index 0000000..f6d8b4e --- /dev/null +++ "b/@apps/backend/src/templates/template1/sections/comp\303\251tences.html" @@ -0,0 +1,16 @@ +
+

{{title}}

+
    + {{#each items}} +
  • + {{this.competence}} + {{#if this.level}} +
    +
    +
    + {{this.level}} + {{/if}} +
  • + {{/each}} +
+
diff --git "a/@apps/backend/src/templates/template1/sections/exp\303\251riences.html" "b/@apps/backend/src/templates/template1/sections/exp\303\251riences.html" new file mode 100644 index 0000000..fb1a2f2 --- /dev/null +++ "b/@apps/backend/src/templates/template1/sections/exp\303\251riences.html" @@ -0,0 +1,21 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.position}} + +
    +
    + {{this.employer}}{{#if this.city}} — {{this.city}}{{/if}} +
    + {{#if this.description}} +

    {{this.description}}

    + {{/if}} +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template1/sections/formations.html b/@apps/backend/src/templates/template1/sections/formations.html new file mode 100644 index 0000000..53e8e90 --- /dev/null +++ b/@apps/backend/src/templates/template1/sections/formations.html @@ -0,0 +1,19 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.degree}} + +
    +
    + {{this.school}}{{#if this.field}} — {{this.field}}{{/if}} +
    + {{#if this.description}} +

    {{this.description}}

    + {{/if}} +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template1/sections/informations-personnelles.html b/@apps/backend/src/templates/template1/sections/informations-personnelles.html new file mode 100644 index 0000000..73014f0 --- /dev/null +++ b/@apps/backend/src/templates/template1/sections/informations-personnelles.html @@ -0,0 +1,34 @@ +
+
+ {{#if item.profilePicture}} + Photo de profil + {{else}} + Photo de profil par défaut + {{/if}} + +
{{item.firstName}} {{item.lastName}}
+
+ +
    + {{#if item.email}} +
  • + Email + {{item.email}} +
  • + {{/if}} {{#if item.phone}} +
  • + Téléphone + {{item.phone}} +
  • + {{/if}} {{#if item.address}} +
  • + Adresse + {{item.address}} +
  • + {{/if}} +
+
diff --git a/@apps/backend/src/templates/template1/sections/langues.html b/@apps/backend/src/templates/template1/sections/langues.html new file mode 100644 index 0000000..235ac4b --- /dev/null +++ b/@apps/backend/src/templates/template1/sections/langues.html @@ -0,0 +1,13 @@ +
+

{{title}}

+
    + {{#each items}} +
  • + {{this.language}} + {{#if this.level}} + {{this.level}} + {{/if}} +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template1/sections/profil.html b/@apps/backend/src/templates/template1/sections/profil.html new file mode 100644 index 0000000..3d8623a --- /dev/null +++ b/@apps/backend/src/templates/template1/sections/profil.html @@ -0,0 +1,4 @@ +
+

{{title}}

+

{{item.description}}

+
diff --git "a/@apps/backend/src/templates/template1/sections/r\303\251f\303\251rences.html" "b/@apps/backend/src/templates/template1/sections/r\303\251f\303\251rences.html" new file mode 100644 index 0000000..ad7e5c1 --- /dev/null +++ "b/@apps/backend/src/templates/template1/sections/r\303\251f\303\251rences.html" @@ -0,0 +1,24 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.name}} +
    + {{#if this.entreprise}} +
    + {{this.entreprise}}{{#if this.city}} — {{this.city}}{{/if}} +
    + {{/if}} +
      + {{#if this.phone}} +
    • Tél. {{this.phone}}
    • + {{/if}} {{#if this.email}} +
    • Email {{this.email}}
    • + {{/if}} +
    +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template1/style.css b/@apps/backend/src/templates/template1/style.css new file mode 100644 index 0000000..e2aa2d1 --- /dev/null +++ b/@apps/backend/src/templates/template1/style.css @@ -0,0 +1,234 @@ +/* stylelint-disable */ + +/* ── Reset & Base Impression ─────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +@page { + size: A4 portrait; + margin: 0; /* On gère les marges en CSS pour le fond perdu */ +} + +html { + /* L'astuce cruciale : Le fond est dessiné sur toute la hauteur du doc */ + background: linear-gradient(to right, #1a2e4a 0%, #1a2e4a 72mm, #ffffff 72mm, #ffffff 100%); + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +body { + font-family: "Georgia", "Times New Roman", serif; + font-size: 10pt; + line-height: 1.5; + color: #2d2d2d; + background: transparent; /* Laisse voir le fond du html */ + padding: 15mm 0; /* Simule les marges haut/bas sur chaque page */ +} + +/* ── Structure ────────────────────────────────────────── */ +.cv { + display: grid; + grid-template-columns: 72mm 1fr; + width: 210mm; + margin: 0 auto; +} + +/* ── Sidebar ──────────────────────────────────────────── */ +.cv__sidebar { + color: #ecf0f1; + padding: 0 7mm 10mm 7mm; /* Marge haut gérée par le body */ + display: flex; + flex-direction: column; + gap: 8mm; +} + +/* ── Main ─────────────────────────────────────────────── */ +.cv__main { + padding: 0 10mm 10mm 8mm; /* Marge haut gérée par le body */ + display: flex; + flex-direction: column; + gap: 7mm; +} + +/* ── Sécurité Sauts de Page ──────────────────────────── */ +.cv-section, +.cv-section__entry, +.cv-section__competence, +.cv-section__langue { + break-inside: avoid; + page-break-inside: avoid; +} + +/* ── Titres ──────────────────────────────────────────── */ +.cv-section__title { + font-size: 9pt; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + padding-bottom: 2mm; + margin-bottom: 4mm; + border-bottom: 0.4mm solid currentColor; +} + +.cv__main .cv-section__title { + color: #1a2e4a; + border-color: #1a2e4a; +} + +.cv__sidebar .cv-section__title { + color: #fff; + border-color: rgba(255, 255, 255, 0.35); +} + +/* ── Photo & Profil ──────────────────────────────────── */ +.cv-section__personal-header { + text-align: center; + margin-bottom: 5mm; +} + +.cv-section__profile-picture { + width: 32mm; + height: 32mm; + border-radius: 50%; + object-fit: cover; + border: 1mm solid rgba(255, 255, 255, 0.2); + background: #fff; + margin: 0 auto 4mm; + display: block; +} + +.cv-section__personal-name { + font-size: 18pt; + font-weight: 700; + line-height: 1.2; + color: #fff; + text-align: center; + margin-bottom: 6mm; +} + +/* ── Détails & Listes ────────────────────────────────── */ +.cv-section__personal-details { + list-style: none; + display: flex; + flex-direction: column; + gap: 4mm; +} + +.cv-section__personal-detail-label { + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.6; + margin-bottom: 0.5mm; +} + +.cv-section__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 6mm; +} + +.cv-section__entry-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 2mm; +} + +.cv-section__entry-degree { + font-weight: 700; + font-size: 11pt; + color: #1a2e4a; +} + +.cv-section__entry-dates { + font-size: 8.5pt; + color: #666; + font-weight: 600; +} + +.cv-section__entry-school { + font-size: 9.5pt; + color: #666; + font-style: italic; + margin-bottom: 1.5mm; +} + +.cv-section__entry-desc { + font-size: 9pt; + color: #444; + text-align: justify; +} + +/* ── Compétences (Jauges) ─────────────────────────────── */ +.cv-section__competence { + display: flex; + align-items: center; + gap: 3mm; + margin-bottom: 3mm; +} + +.cv-section__competence-name { + font-size: 9pt; + flex: 1; +} + +.cv-section__competence-bar-wrap { + width: 25mm; + height: 1.5mm; + background: rgba(255, 255, 255, 0.2); + border-radius: 1mm; + overflow: hidden; +} + +.cv-section__competence-bar { + height: 100%; + background: rgba(255, 255, 255, 0.7); +} + +/* Niveaux */ +.cv-section__competence-bar--Expert { + width: 100%; +} +.cv-section__competence-bar--Avancé { + width: 75%; +} +.cv-section__competence-bar--Intermédiaire { + width: 50%; +} + +/* ── Badges (Langues & Intérêts) ─────────────────────── */ +.cv-section__langues-list, +.cv-section__centres-list { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 2mm; +} + +.cv-section__langue, +.cv-section__centre { + font-size: 8.5pt; + background: rgba(255, 255, 255, 0.12); + padding: 1mm 2.5mm; + border-radius: 1mm; +} + +/* ── Ajustements spécifiques pour l'écran (Preview) ─── */ +@media screen { + body { + background: #e0e0e0; + padding: 20px; + } + .cv { + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + min-height: 297mm; + background: white; /* Pour la préview écran */ + } +} diff --git a/@apps/backend/src/templates/template2/base.html b/@apps/backend/src/templates/template2/base.html new file mode 100644 index 0000000..eaa4a55 --- /dev/null +++ b/@apps/backend/src/templates/template2/base.html @@ -0,0 +1,17 @@ + + + + + + + + +
+
{{#each leftSections}} {{{this.html}}} {{/each}}
+ +
{{#each rightSections}} {{{this.html}}} {{/each}}
+
+ + diff --git "a/@apps/backend/src/templates/template2/sections/centres-d'int\303\251r\303\252t.html" "b/@apps/backend/src/templates/template2/sections/centres-d'int\303\251r\303\252t.html" new file mode 100644 index 0000000..73605e6 --- /dev/null +++ "b/@apps/backend/src/templates/template2/sections/centres-d'int\303\251r\303\252t.html" @@ -0,0 +1,8 @@ +
+

{{title}}

+
    + {{#each items}} +
  • {{this.name}}
  • + {{/each}} +
+
diff --git "a/@apps/backend/src/templates/template2/sections/comp\303\251tences.html" "b/@apps/backend/src/templates/template2/sections/comp\303\251tences.html" new file mode 100644 index 0000000..f6d8b4e --- /dev/null +++ "b/@apps/backend/src/templates/template2/sections/comp\303\251tences.html" @@ -0,0 +1,16 @@ +
+

{{title}}

+
    + {{#each items}} +
  • + {{this.competence}} + {{#if this.level}} +
    +
    +
    + {{this.level}} + {{/if}} +
  • + {{/each}} +
+
diff --git "a/@apps/backend/src/templates/template2/sections/exp\303\251riences.html" "b/@apps/backend/src/templates/template2/sections/exp\303\251riences.html" new file mode 100644 index 0000000..fb1a2f2 --- /dev/null +++ "b/@apps/backend/src/templates/template2/sections/exp\303\251riences.html" @@ -0,0 +1,21 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.position}} + +
    +
    + {{this.employer}}{{#if this.city}} — {{this.city}}{{/if}} +
    + {{#if this.description}} +

    {{this.description}}

    + {{/if}} +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template2/sections/formations.html b/@apps/backend/src/templates/template2/sections/formations.html new file mode 100644 index 0000000..53e8e90 --- /dev/null +++ b/@apps/backend/src/templates/template2/sections/formations.html @@ -0,0 +1,19 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.degree}} + +
    +
    + {{this.school}}{{#if this.field}} — {{this.field}}{{/if}} +
    + {{#if this.description}} +

    {{this.description}}

    + {{/if}} +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template2/sections/informations-personnelles.html b/@apps/backend/src/templates/template2/sections/informations-personnelles.html new file mode 100644 index 0000000..73014f0 --- /dev/null +++ b/@apps/backend/src/templates/template2/sections/informations-personnelles.html @@ -0,0 +1,34 @@ +
+
+ {{#if item.profilePicture}} + Photo de profil + {{else}} + Photo de profil par défaut + {{/if}} + +
{{item.firstName}} {{item.lastName}}
+
+ +
    + {{#if item.email}} +
  • + Email + {{item.email}} +
  • + {{/if}} {{#if item.phone}} +
  • + Téléphone + {{item.phone}} +
  • + {{/if}} {{#if item.address}} +
  • + Adresse + {{item.address}} +
  • + {{/if}} +
+
diff --git a/@apps/backend/src/templates/template2/sections/langues.html b/@apps/backend/src/templates/template2/sections/langues.html new file mode 100644 index 0000000..235ac4b --- /dev/null +++ b/@apps/backend/src/templates/template2/sections/langues.html @@ -0,0 +1,13 @@ +
+

{{title}}

+
    + {{#each items}} +
  • + {{this.language}} + {{#if this.level}} + {{this.level}} + {{/if}} +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template2/sections/profil.html b/@apps/backend/src/templates/template2/sections/profil.html new file mode 100644 index 0000000..3d8623a --- /dev/null +++ b/@apps/backend/src/templates/template2/sections/profil.html @@ -0,0 +1,4 @@ +
+

{{title}}

+

{{item.description}}

+
diff --git "a/@apps/backend/src/templates/template2/sections/r\303\251f\303\251rences.html" "b/@apps/backend/src/templates/template2/sections/r\303\251f\303\251rences.html" new file mode 100644 index 0000000..ad7e5c1 --- /dev/null +++ "b/@apps/backend/src/templates/template2/sections/r\303\251f\303\251rences.html" @@ -0,0 +1,24 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.name}} +
    + {{#if this.entreprise}} +
    + {{this.entreprise}}{{#if this.city}} — {{this.city}}{{/if}} +
    + {{/if}} +
      + {{#if this.phone}} +
    • Tél. {{this.phone}}
    • + {{/if}} {{#if this.email}} +
    • Email {{this.email}}
    • + {{/if}} +
    +
  • + {{/each}} +
+
diff --git a/@apps/backend/src/templates/template2/style.css b/@apps/backend/src/templates/template2/style.css new file mode 100644 index 0000000..2ad7461 --- /dev/null +++ b/@apps/backend/src/templates/template2/style.css @@ -0,0 +1,233 @@ +/* stylelint-disable */ + +/* ── Reset & Base Impression ─────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +@page { + size: A4 portrait; + margin: 0; +} + +html { + background: linear-gradient(to right, #2d1b4e 0%, #2d1b4e 72mm, #fafaf8 72mm, #fafaf8 100%); + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +body { + font-family: "Helvetica Neue", "Arial", sans-serif; + font-size: 10pt; + line-height: 1.5; + color: #1c1c1c; + background: transparent; + padding: 15mm 0; +} + +/* ── Structure ────────────────────────────────────────── */ +.cv { + display: grid; + grid-template-columns: 72mm 1fr; + width: 210mm; + margin: 0 auto; +} + +/* ── Sidebar ──────────────────────────────────────────── */ +.cv__sidebar { + color: #f0e6ff; + padding: 0 7mm 10mm 7mm; + display: flex; + flex-direction: column; + gap: 8mm; +} + +/* ── Main ─────────────────────────────────────────────── */ +.cv__main { + padding: 0 10mm 10mm 8mm; + display: flex; + flex-direction: column; + gap: 7mm; +} + +/* ── Sécurité Sauts de Page ──────────────────────────── */ +.cv-section, +.cv-section__entry, +.cv-section__competence, +.cv-section__langue { + break-inside: avoid; + page-break-inside: avoid; +} + +/* ── Titres ──────────────────────────────────────────── */ +.cv-section__title { + font-size: 9pt; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + padding-bottom: 2mm; + margin-bottom: 4mm; + border-bottom: 0.4mm solid currentColor; +} + +.cv__main .cv-section__title { + color: #2d1b4e; + border-color: #2d1b4e; +} + +.cv__sidebar .cv-section__title { + color: #e8d5ff; + border-color: rgba(232, 213, 255, 0.35); +} + +/* ── Photo & Profil ──────────────────────────────────── */ +.cv-section__personal-header { + text-align: center; + margin-bottom: 5mm; +} + +.cv-section__profile-picture { + width: 32mm; + height: 32mm; + border-radius: 50%; + object-fit: cover; + border: 1mm solid rgba(232, 213, 255, 0.3); + background: #fff; + margin: 0 auto 4mm; + display: block; +} + +.cv-section__personal-name { + font-size: 18pt; + font-weight: 300; + letter-spacing: 0.05em; + line-height: 1.2; + color: #f0e6ff; + text-align: center; + margin-bottom: 6mm; +} + +/* ── Détails & Listes ────────────────────────────────── */ +.cv-section__personal-details { + list-style: none; + display: flex; + flex-direction: column; + gap: 4mm; +} + +.cv-section__personal-detail-label { + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.6; + margin-bottom: 0.5mm; +} + +.cv-section__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 6mm; +} + +.cv-section__entry-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 2mm; +} + +.cv-section__entry-degree { + font-weight: 700; + font-size: 11pt; + color: #2d1b4e; +} + +.cv-section__entry-dates { + font-size: 8.5pt; + color: #9b7fc7; + font-weight: 600; +} + +.cv-section__entry-school { + font-size: 9.5pt; + color: #9b7fc7; + font-style: italic; + margin-bottom: 1.5mm; +} + +.cv-section__entry-desc { + font-size: 9pt; + color: #444; + text-align: justify; +} + +/* ── Compétences (Jauges) ─────────────────────────────── */ +.cv-section__competence { + display: flex; + align-items: center; + gap: 3mm; + margin-bottom: 3mm; +} + +.cv-section__competence-name { + font-size: 9pt; + flex: 1; +} + +.cv-section__competence-bar-wrap { + width: 25mm; + height: 1.5mm; + background: rgba(232, 213, 255, 0.2); + border-radius: 1mm; + overflow: hidden; +} + +.cv-section__competence-bar { + height: 100%; + background: rgba(232, 213, 255, 0.75); +} + +.cv-section__competence-bar--Expert { + width: 100%; +} +.cv-section__competence-bar--Avancé { + width: 75%; +} +.cv-section__competence-bar--Intermédiaire { + width: 50%; +} + +/* ── Badges (Langues & Intérêts) ─────────────────────── */ +.cv-section__langues-list, +.cv-section__centres-list { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 2mm; +} + +.cv-section__langue, +.cv-section__centre { + font-size: 8.5pt; + background: rgba(232, 213, 255, 0.15); + padding: 1mm 2.5mm; + border-radius: 1mm; +} + +/* ── Ajustements spécifiques pour l'écran (Preview) ─── */ +@media screen { + body { + background: #e8e0f0; + padding: 20px; + } + .cv { + box-shadow: 0 10px 30px rgba(45, 27, 78, 0.25); + min-height: 297mm; + background: white; + } +} diff --git a/@apps/front/public/assets/img/default-avatar.png b/@apps/front/public/assets/img/default_avatar.png similarity index 100% rename from @apps/front/public/assets/img/default-avatar.png rename to @apps/front/public/assets/img/default_avatar.png diff --git a/@apps/front/public/templates/template1/sections/informations-personnelles.html b/@apps/front/public/templates/template1/sections/informations-personnelles.html index 5448ee5..b0917bb 100644 --- a/@apps/front/public/templates/template1/sections/informations-personnelles.html +++ b/@apps/front/public/templates/template1/sections/informations-personnelles.html @@ -6,6 +6,12 @@ src={{item.profilePicture}} alt="Photo de profil" /> + {{else}} + Photo de profil par défaut {{/if}}
diff --git a/@apps/front/public/templates/template2/base.html b/@apps/front/public/templates/template2/base.html new file mode 100644 index 0000000..234a378 --- /dev/null +++ b/@apps/front/public/templates/template2/base.html @@ -0,0 +1,15 @@ + +
+
+ {{#each leftSections}} + {{{this.html}}} + {{/each}} +
+
+ {{#each rightSections}} + {{{this.html}}} + {{/each}} +
+
\ No newline at end of file diff --git "a/@apps/front/public/templates/template2/sections/centres-d'int\303\251r\303\252t.html" "b/@apps/front/public/templates/template2/sections/centres-d'int\303\251r\303\252t.html" new file mode 100644 index 0000000..c3e0502 --- /dev/null +++ "b/@apps/front/public/templates/template2/sections/centres-d'int\303\251r\303\252t.html" @@ -0,0 +1,8 @@ +
+

{{title}}

+
    + {{#each items}} +
  • {{this.name}}
  • + {{/each}} +
+
\ No newline at end of file diff --git "a/@apps/front/public/templates/template2/sections/comp\303\251tences.html" "b/@apps/front/public/templates/template2/sections/comp\303\251tences.html" new file mode 100644 index 0000000..12a8f30 --- /dev/null +++ "b/@apps/front/public/templates/template2/sections/comp\303\251tences.html" @@ -0,0 +1,16 @@ +
+

{{title}}

+
    + {{#each items}} +
  • + {{this.competence}} + {{#if this.level}} +
    +
    +
    + {{this.level}} + {{/if}} +
  • + {{/each}} +
+
\ No newline at end of file diff --git "a/@apps/front/public/templates/template2/sections/exp\303\251riences.html" "b/@apps/front/public/templates/template2/sections/exp\303\251riences.html" new file mode 100644 index 0000000..6f49ece --- /dev/null +++ "b/@apps/front/public/templates/template2/sections/exp\303\251riences.html" @@ -0,0 +1,17 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.position}} + +
    +
    {{this.employer}}{{#if this.city}} — {{this.city}}{{/if}}
    + {{#if this.description}} +

    {{this.description}}

    + {{/if}} +
  • + {{/each}} +
+
\ No newline at end of file diff --git a/@apps/front/public/templates/template2/sections/formations.html b/@apps/front/public/templates/template2/sections/formations.html new file mode 100644 index 0000000..873f320 --- /dev/null +++ b/@apps/front/public/templates/template2/sections/formations.html @@ -0,0 +1,17 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.degree}} + +
    +
    {{this.school}}{{#if this.field}} — {{this.field}}{{/if}}
    + {{#if this.description}} +

    {{this.description}}

    + {{/if}} +
  • + {{/each}} +
+
\ No newline at end of file diff --git a/@apps/front/public/templates/template2/sections/informations-personnelles.html b/@apps/front/public/templates/template2/sections/informations-personnelles.html new file mode 100644 index 0000000..b0917bb --- /dev/null +++ b/@apps/front/public/templates/template2/sections/informations-personnelles.html @@ -0,0 +1,44 @@ +
+
+ {{#if item.profilePicture}} + Photo de profil + {{else}} + Photo de profil par défaut + {{/if}} + +
+ {{item.firstName}} {{item.lastName}} +
+
+ +
    + {{#if item.email}} +
  • + Email + {{item.email}} +
  • + {{/if}} + + {{#if item.phone}} +
  • + Téléphone + {{item.phone}} +
  • + {{/if}} + + {{#if item.address}} +
  • + Adresse + {{item.address}} +
  • + {{/if}} +
+
\ No newline at end of file diff --git a/@apps/front/public/templates/template2/sections/langues.html b/@apps/front/public/templates/template2/sections/langues.html new file mode 100644 index 0000000..02c7ca7 --- /dev/null +++ b/@apps/front/public/templates/template2/sections/langues.html @@ -0,0 +1,13 @@ +
+

{{title}}

+
    + {{#each items}} +
  • + {{this.language}} + {{#if this.level}} + {{this.level}} + {{/if}} +
  • + {{/each}} +
+
\ No newline at end of file diff --git a/@apps/front/public/templates/template2/sections/profil.html b/@apps/front/public/templates/template2/sections/profil.html new file mode 100644 index 0000000..10fc94f --- /dev/null +++ b/@apps/front/public/templates/template2/sections/profil.html @@ -0,0 +1,4 @@ +
+

{{title}}

+

{{item.description}}

+
\ No newline at end of file diff --git "a/@apps/front/public/templates/template2/sections/r\303\251f\303\251rences.html" "b/@apps/front/public/templates/template2/sections/r\303\251f\303\251rences.html" new file mode 100644 index 0000000..261fc96 --- /dev/null +++ "b/@apps/front/public/templates/template2/sections/r\303\251f\303\251rences.html" @@ -0,0 +1,23 @@ +
+

{{title}}

+
    + {{#each items}} +
  • +
    + {{this.name}} +
    + {{#if this.entreprise}} +
    {{this.entreprise}}{{#if this.city}} — {{this.city}}{{/if}}
    + {{/if}} +
      + {{#if this.phone}} +
    • Tél. {{this.phone}}
    • + {{/if}} + {{#if this.email}} +
    • Email {{this.email}}
    • + {{/if}} +
    +
  • + {{/each}} +
+
\ No newline at end of file diff --git a/@apps/front/public/templates/template2/style.css b/@apps/front/public/templates/template2/style.css new file mode 100644 index 0000000..0fdd688 --- /dev/null +++ b/@apps/front/public/templates/template2/style.css @@ -0,0 +1,346 @@ +/* stylelint-disable */ +/* ── Reset & base ─────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Variables ────────────────────────────────────────── */ +.cv-preview-container { + --cv-sidebar-bg: #2d1b4e; + --cv-sidebar-color: #f0e6ff; + --cv-accent: #2d1b4e; + --cv-text: #1c1c1c; + --cv-muted: #9b7fc7; + --cv-page-width: 210mm; + --cv-page-height: 297mm; + --cv-sidebar-width: 72mm; + --cv-bar-color: rgba(232, 213, 255, 0.75); + --cv-bar-bg: rgba(232, 213, 255, 0.2); +} + +/* ── Conteneur preview ────────────────────────────────── */ +.cv-preview-container { + display: flex; + justify-content: center; + align-items: flex-start; + background: #c8c8c8; + padding: 32px; + min-height: 100vh; +} + +/* ── Page A4 fixe ─────────────────────────────────────── */ +.cv { + display: grid; + grid-template-columns: var(--cv-sidebar-width) 1fr; + width: var(--cv-page-width); + min-height: var(--cv-page-height); + background: #fafaf8; + color: var(--cv-text); + font-family: 'Helvetica Neue', 'Arial', sans-serif; + font-size: 10pt; + line-height: 1.5; + box-shadow: + 0 4px 32px rgba(45, 27, 78, 0.25), + 0 1px 4px rgba(45, 27, 78, 0.1); +} + +/* ── Sidebar ──────────────────────────────────────────── */ +.cv__sidebar { + background: var(--cv-sidebar-bg); + color: var(--cv-sidebar-color); + padding: 10mm 7mm; + display: flex; + flex-direction: column; + gap: 8mm; +} + +/* ── Main ─────────────────────────────────────────────── */ +.cv__main { + padding: 10mm 10mm 10mm 8mm; + display: flex; + flex-direction: column; + gap: 7mm; +} + +/* ── Titres de section ────────────────────────────────── */ +.cv-section__title { + font-size: 9pt; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + padding-bottom: 2mm; + margin-bottom: 4mm; + border-bottom: 0.4mm solid currentColor; +} + +.cv__main .cv-section__title { + color: var(--cv-accent); + border-color: var(--cv-accent); +} +.cv__sidebar .cv-section__title { + color: #e8d5ff; + border-color: rgba(232, 213, 255, 0.35); +} + +/* ── Informations personnelles ────────────────────────── */ +.cv-section__personal-header { + display: flex; + align-items: center; + gap: 5mm; + margin-bottom: 5mm; +} + +/* Photo de profil */ +.cv-section__profile-picture { + width: 28mm; + height: 28mm; + border-radius: 50%; + object-fit: cover; + border: 0.6mm solid rgba(232, 213, 255, 0.4); + background: #fff; +} + +.cv-section__personal-name { + font-size: 18pt; + font-weight: 300; + letter-spacing: 0.05em; + line-height: 1.2; + margin-bottom: 5mm; + color: #f0e6ff; + word-break: break-word; +} + +.cv-section__personal-details { + list-style: none; + display: flex; + flex-direction: column; + gap: 3mm; +} + +.cv-section__personal-detail { + display: flex; + flex-direction: column; + gap: 0.5mm; +} + +.cv-section__personal-detail-label { + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.55; +} + +.cv-section__personal-detail span:last-child { + font-size: 9pt; + word-break: break-all; +} + +/* ── Profil ───────────────────────────────────────────── */ +.cv-section__profil-desc { + font-size: 9.5pt; + line-height: 1.6; + color: var(--cv-text); + text-align: justify; +} + +/* ── Listes génériques (formations, expériences, refs) ── */ +.cv-section__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 5mm; +} + +.cv-section__entry-header { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 2mm; + margin-bottom: 1mm; +} + +.cv-section__entry-degree { + font-weight: 700; + font-size: 11pt; + color: var(--cv-accent); +} +.cv-section__entry-dates { + font-size: 8pt; + color: var(--cv-muted); + white-space: nowrap; + flex-shrink: 0; +} +.cv-section__entry-school { + font-size: 9pt; + color: var(--cv-muted); + font-style: italic; + margin-bottom: 2mm; +} +.cv-section__entry-desc { + font-size: 9pt; + line-height: 1.55; + color: #444; + text-align: justify; +} + +/* ── Références ───────────────────────────────────────── */ +.cv-section__ref-contacts { + list-style: none; + display: flex; + flex-direction: column; + gap: 1mm; + margin-top: 1.5mm; + font-size: 9pt; +} + +.cv-section__ref-contacts li { + display: flex; + gap: 2mm; + align-items: baseline; +} + +/* ── Compétences ──────────────────────────────────────── */ +.cv-section__competences-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 3mm; +} + +.cv-section__competence { + display: flex; + align-items: center; + gap: 3mm; +} + +.cv-section__competence-name { + font-size: 9pt; + flex: 1; + min-width: 0; +} +.cv-section__competence-level { + font-size: 7.5pt; + opacity: 0.65; + white-space: nowrap; +} + +.cv-section__competence-bar-wrap { + width: 22mm; + height: 1.5mm; + background: var(--cv-bar-bg); + border-radius: 1mm; + overflow: hidden; + flex-shrink: 0; +} + +.cv-section__competence-bar { + height: 100%; + background: var(--cv-bar-color); + border-radius: 1mm; +} + +.cv-section__competence-bar--Débutant, +.cv-section__competence-bar--debutant { + width: 25%; +} +.cv-section__competence-bar--Intermédiaire, +.cv-section__competence-bar--intermediaire { + width: 50%; +} +.cv-section__competence-bar--Avancé, +.cv-section__competence-bar--avance { + width: 75%; +} +.cv-section__competence-bar--Expert, +.cv-section__competence-bar--expert { + width: 100%; +} + +/* ── Langues ──────────────────────────────────────────── */ +.cv-section__langues-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 2.5mm; +} + +.cv-section__langue { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 9pt; +} + +.cv-section__langue-name { + font-weight: 600; +} +.cv-section__langue-niveau { + font-size: 8pt; + opacity: 0.75; + background: rgba(232, 213, 255, 0.15); + padding: 0.5mm 2mm; + border-radius: 1mm; +} + +/* ── Centres d'intérêt ────────────────────────────────── */ +.cv-section__centres-list { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 2mm; +} + +.cv-section__centre { + font-size: 8.5pt; + background: rgba(255, 255, 255, 0.15); + padding: 1mm 3mm; + border-radius: 2mm; +} + +/* ── Print ────────────────────────────────────────────── */ +@media print { + @page { + size: A4 portrait; + margin: 0; + } + + html, + body { + width: 210mm; + height: 297mm; + background: #fff !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .cv-preview-container { + padding: 0 !important; + background: none !important; + min-height: unset; + } + + .cv { + width: 210mm; + min-height: 297mm; + box-shadow: none !important; + page-break-after: always; + break-after: page; + } + + .cv__sidebar { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .cv-section__entry, + .cv-section__competence, + .cv-section__langue { + break-inside: avoid; + page-break-inside: avoid; + } +} diff --git a/@apps/front/translations/curriculums/en-us.yaml b/@apps/front/translations/curriculums/en-us.yaml index 605a530..6d150d4 100644 --- a/@apps/front/translations/curriculums/en-us.yaml +++ b/@apps/front/translations/curriculums/en-us.yaml @@ -24,3 +24,5 @@ edit: noFileChosen: 'No file chosen' newItem: 'New item' noTitle: 'Untitled' + selectTemplate: 'Select a template' + chooseFile: 'Choose a file' diff --git a/@apps/front/translations/curriculums/fr-fr.yaml b/@apps/front/translations/curriculums/fr-fr.yaml index 06ba567..3529e49 100644 --- a/@apps/front/translations/curriculums/fr-fr.yaml +++ b/@apps/front/translations/curriculums/fr-fr.yaml @@ -24,3 +24,5 @@ edit: noFileChosen: 'Aucun fichier choisi' newItem: 'Nouvel élément' noTitle: 'Sans titre' + selectTemplate: 'Sélectionnez un modèle' + chooseFile: 'Choisir un fichier' diff --git a/@libs/users-backend/package.json b/@libs/users-backend/package.json index 1ac2c18..9b17c03 100644 --- a/@libs/users-backend/package.json +++ b/@libs/users-backend/package.json @@ -26,7 +26,8 @@ "schema:update": "pnpm mikro-orm schema:update --run", "schema:drop": "pnpm mikro-orm schema:drop --run", "seed": "pnpm mikro-orm seeder:run", - "schema:fresh": "pnpm mikro-orm schema:fresh --seed -r" + "schema:fresh": "pnpm mikro-orm schema:fresh --seed -r", + "postinstall": "puppeteer browsers install chrome" }, "dependencies": { "@fastify/cookie": "catalog:", @@ -45,12 +46,14 @@ "fastify": "catalog:", "fastify-type-provider-zod": "catalog:", "fastify-zod": "catalog:", + "handlebars": "catalog:", "mailgen": "catalog:", "nodemailer": "catalog:", "passport-local": "catalog:", "pino": "catalog:", "pino-pretty": "catalog:", "playwright": "^1.58.1", + "puppeteer": "^24.41.0", "true-myth": "catalog:", "ts-dotenv": "catalog:", "vitest": "catalog:", diff --git a/@libs/users-backend/src/entities/sections.entity.ts b/@libs/users-backend/src/entities/sections.entity.ts index 17c40ed..7aaab74 100644 --- a/@libs/users-backend/src/entities/sections.entity.ts +++ b/@libs/users-backend/src/entities/sections.entity.ts @@ -12,6 +12,7 @@ export const SectionsEntity = defineEntity({ title: p.string(), position: p.integer(), items: () => p.oneToMany(SectionItemsEntity).mappedBy("section").cascade(Cascade.ALL), + isActive: p.boolean(), }, }); diff --git a/@libs/users-backend/src/index.ts b/@libs/users-backend/src/index.ts index 275e80f..c6671eb 100644 --- a/@libs/users-backend/src/index.ts +++ b/@libs/users-backend/src/index.ts @@ -41,6 +41,8 @@ export * from "#src/routes/curriculums/create.route.js"; export * from "#src/routes/curriculums/update.route.js"; export * from "#src/routes/curriculums/delete.route.js"; export * from "#src/routes/curriculums/duplicate.route.js"; +export * from "#src/routes/curriculums/export.route.js"; +export * from "#src/routes/curriculums/list.models.route.js"; export * from "#src/routes/section-templates/list.route.js"; diff --git a/@libs/users-backend/src/init.ts b/@libs/users-backend/src/init.ts index 4f8f89a..092b61c 100644 --- a/@libs/users-backend/src/init.ts +++ b/@libs/users-backend/src/init.ts @@ -38,6 +38,8 @@ import { CreateCurriculumRoute } from "./routes/curriculums/create.route.ts"; import { UpdateCurriculumRoute } from "./routes/curriculums/update.route.ts"; import { DeleteCurriculumRoute } from "./routes/curriculums/delete.route.ts"; import { DuplicateCurriculumRoute } from "./routes/curriculums/duplicate.route.ts"; +import { ExportCurriculumRoute } from "./routes/curriculums/export.route.ts"; +import { ListModelsCurriculumRoute } from "./routes/curriculums/list.models.route.ts"; import { GetSectionsRoute } from "./routes/sections/get.route.ts"; import { CreateSectionsRoute } from "./routes/sections/create.route.ts"; @@ -156,7 +158,11 @@ export class CurriculumModule implements ModuleInterface[] = [ new GetCurriculumRoute(repository), new ListCurriculumRoute(repository), - new CreateCurriculumRoute(repository), + new CreateCurriculumRoute( + repository, + this.context.em.getRepository(SectionsEntity), + this.context.em.getRepository(SectionTemplatesEntity), + ), new UpdateCurriculumRoute(repository), new DeleteCurriculumRoute(repository), new DuplicateCurriculumRoute( @@ -164,6 +170,8 @@ export class CurriculumModule implements ModuleInterface { diff --git a/@libs/users-backend/src/routes/curriculums/create.route.ts b/@libs/users-backend/src/routes/curriculums/create.route.ts index 1214a41..5779656 100644 --- a/@libs/users-backend/src/routes/curriculums/create.route.ts +++ b/@libs/users-backend/src/routes/curriculums/create.route.ts @@ -1,5 +1,7 @@ import type { FastifyInstanceTypeForModule } from "#src/init.js"; import type { CurriculumEntityType } from "#src/entities/curriculum.entity.js"; +import type { SectionEntityType } from "#src/entities/sections.entity.js"; +import type { SectionTemplatesEntityType } from "#src/entities/section-templates.entity.js"; import type { EntityRepository } from "@mikro-orm/core"; import { randomUUID } from "crypto"; import { @@ -9,7 +11,11 @@ import { import { makeSingleJsonApiTopDocument, type Route } from "@libs/backend-shared"; export class CreateCurriculumRoute implements Route { - public constructor(private curriculumRepository: EntityRepository) {} + public constructor( + private curriculumRepository: EntityRepository, + private sectionsRepository: EntityRepository, + private sectionTemplatesRepository: EntityRepository, + ) {} public routeDefinition(f: FastifyInstanceTypeForModule) { return f.post( @@ -32,6 +38,21 @@ export class CreateCurriculumRoute implements Route { createdAt: new Date(), }); + const templates = await this.sectionTemplatesRepository.findAll(); + + for (const template of templates) { + const section = this.sectionsRepository.create({ + id: randomUUID(), + curriculum, + template, + title: template.label, + position: template.position, + isActive: false, + }); + + curriculum.sections.add(section); + } + await this.curriculumRepository.getEntityManager().flush(); return reply.send(jsonApiSerializeSingleCurriculumDocument(curriculum)); diff --git a/@libs/users-backend/src/routes/curriculums/duplicate.route.ts b/@libs/users-backend/src/routes/curriculums/duplicate.route.ts index f1f30d7..b376d4a 100644 --- a/@libs/users-backend/src/routes/curriculums/duplicate.route.ts +++ b/@libs/users-backend/src/routes/curriculums/duplicate.route.ts @@ -78,6 +78,7 @@ export class DuplicateCurriculumRoute implements Route { template: section.template, title: section.title, position: section.position, + isActive: section.isActive, }); for (const item of section.items) { diff --git a/@libs/users-backend/src/routes/curriculums/export.route.ts b/@libs/users-backend/src/routes/curriculums/export.route.ts new file mode 100644 index 0000000..bc6fcc3 --- /dev/null +++ b/@libs/users-backend/src/routes/curriculums/export.route.ts @@ -0,0 +1,58 @@ +import type { FastifyInstanceTypeForModule } from "#src/init.js"; +import type { EntityRepository } from "@mikro-orm/core"; +import { object, string } from "zod"; +import type { CurriculumEntityType } from "#src/entities/curriculum.entity.ts"; +import { jsonApiErrorDocumentSchema, makeJsonApiError, type Route } from "@libs/backend-shared"; +import renderCv from "#src/services/template.service.js"; +import pdfService from "#src/services/pdf.service.js"; +import { z } from "zod"; + +export class ExportCurriculumRoute implements Route { + public constructor(private curriculumRepository: EntityRepository) {} + + public routeDefinition(f: FastifyInstanceTypeForModule) { + return f.get( + "/:id/export/:modelId", + { + schema: { + params: object({ + id: string(), + modelId: string(), + }), + response: { + 200: z.instanceof(Buffer), + 404: jsonApiErrorDocumentSchema, + }, + }, + }, + async (request, reply) => { + const { id, modelId } = request.params as { id: string; modelId: string }; + const currentUser = request.user!; + + const curriculum = await this.curriculumRepository.findOne( + { id, userId: currentUser.id }, + { populate: ["sections", "sections.template", "sections.items"] }, + ); + + if (!curriculum) { + return reply.code(404).send( + makeJsonApiError(404, "Not Found", { + code: "CURRICULUM_NOT_FOUND", + detail: `Curriculum with id ${id} not found`, + }), + ); + } + + const html = await renderCv(curriculum.sections.getItems(), modelId); + const pdf = await pdfService.renderHtmlToPdf(html); + + return reply + .code(200) + .header("Content-Type", "application/pdf") + .header("Content-Disposition", `attachment; filename="curriculum-${id}.pdf"`) + .serializer((payload) => payload) + .send(pdf); + }, + ); + } +} diff --git a/@libs/users-backend/src/routes/curriculums/list.models.route.ts b/@libs/users-backend/src/routes/curriculums/list.models.route.ts new file mode 100644 index 0000000..258f371 --- /dev/null +++ b/@libs/users-backend/src/routes/curriculums/list.models.route.ts @@ -0,0 +1,40 @@ +import type { FastifyInstanceTypeForModule } from "#src/init.js"; +import { object, array, string } from "zod"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { jsonApiErrorDocumentSchema, makeJsonApiError, type Route } from "@libs/backend-shared"; + +export class ListModelsCurriculumRoute implements Route { + public routeDefinition(f: FastifyInstanceTypeForModule) { + return f.get( + "/models", + { + schema: { + response: { + 200: object({ + data: array(string()), + }), + 404: jsonApiErrorDocumentSchema, + }, + }, + }, + async (request, reply) => { + const templatesPath = path.join(process.cwd(), "src", "templates"); + + const entries = await fs.readdir(templatesPath, { withFileTypes: true }); + const folders = entries.filter((e) => e.isDirectory()).map((e) => e.name); + + if (!folders.length) { + return reply.code(404).send( + makeJsonApiError(404, "Not Found", { + code: "CV_TEMPLATES_NOT_FOUND", + detail: "No CV templates found", + }), + ); + } + + return reply.send({ data: folders }); + }, + ); + } +} diff --git a/@libs/users-backend/src/routes/section-items/update.route.ts b/@libs/users-backend/src/routes/section-items/update.route.ts index 4f11cc0..db95cc3 100644 --- a/@libs/users-backend/src/routes/section-items/update.route.ts +++ b/@libs/users-backend/src/routes/section-items/update.route.ts @@ -62,6 +62,9 @@ export class UpdateSectionItemRoute implements Route { } if (attributes) { + if ("profilePicture" in attributes) { + delete attributes["profilePicture"]; + } const templateSchema = parseTemplateSchema(item.section.template.jsonSchema); const allowedKeys = new Set(templateSchema.map((f) => f.key)); const unknownKeys = Object.keys(attributes).filter((key) => !allowedKeys.has(key)); diff --git a/@libs/users-backend/src/routes/sections/create.route.ts b/@libs/users-backend/src/routes/sections/create.route.ts index 3472d96..154d3ea 100644 --- a/@libs/users-backend/src/routes/sections/create.route.ts +++ b/@libs/users-backend/src/routes/sections/create.route.ts @@ -77,6 +77,7 @@ export class CreateSectionsRoute implements Route { template: template, title, position: await this.sectionRepository.count({ curriculum: curriculumId }), + isActive: false, }); await this.sectionRepository.getEntityManager().persist(section).flush(); diff --git a/@libs/users-backend/src/routes/sections/get.route.ts b/@libs/users-backend/src/routes/sections/get.route.ts index 8456d56..18006b0 100644 --- a/@libs/users-backend/src/routes/sections/get.route.ts +++ b/@libs/users-backend/src/routes/sections/get.route.ts @@ -1,6 +1,6 @@ import type { FastifyInstanceTypeForModule } from "#src/init.js"; import type { EntityRepository } from "@mikro-orm/core"; -import { object, string, number, array, record, unknown } from "zod"; +import { object, string, number, array, record, unknown, boolean } from "zod"; import type { SectionEntityType } from "#src/entities/sections.entity.js"; import { jsonApiErrorDocumentSchema, makeJsonApiError, type Route } from "@libs/backend-shared"; import type { CurriculumEntityType } from "#src/entities/curriculum.entity.ts"; @@ -32,6 +32,7 @@ export class GetSectionsRoute implements Route { templateId: string(), title: string(), position: number(), + isActive: boolean(), items: array( object({ id: string(), @@ -85,6 +86,7 @@ export class GetSectionsRoute implements Route { templateId: section.template.id, title: section.title, position: section.position, + isActive: section.isActive, items: section.items.map((item) => ({ id: item.id, position: item.position, diff --git a/@libs/users-backend/src/routes/sections/update.route.ts b/@libs/users-backend/src/routes/sections/update.route.ts index 3b374af..b95c162 100644 --- a/@libs/users-backend/src/routes/sections/update.route.ts +++ b/@libs/users-backend/src/routes/sections/update.route.ts @@ -1,6 +1,6 @@ import type { FastifyInstanceTypeForModule } from "#src/init.js"; import type { EntityRepository } from "@mikro-orm/core"; -import { object, string } from "zod"; +import { boolean, object, string } from "zod"; import type { SectionEntityType } from "#src/entities/sections.entity.ts"; import { jsonApiErrorDocumentSchema, makeJsonApiError, type Route } from "@libs/backend-shared"; import { @@ -17,7 +17,7 @@ export class UpdateSectionsRoute implements Route { { schema: { params: object({ curriculumId: string(), sectionId: string() }), - body: object({ title: string() }), + body: object({ isActive: boolean() }), response: { 200: object({ data: SerializedSectionsSchema }), 404: jsonApiErrorDocumentSchema, @@ -30,7 +30,7 @@ export class UpdateSectionsRoute implements Route { curriculumId: string; sectionId: string; }; - const { title } = request.body as { title: string }; + const { isActive } = request.body as { isActive: boolean }; const section = await this.sectionRepository.findOne({ id: sectionId, @@ -46,7 +46,7 @@ export class UpdateSectionsRoute implements Route { ); } - section.title = title; + section.isActive = isActive; await this.sectionRepository.getEntityManager().flush(); diff --git a/@libs/users-backend/src/services/pdf.service.ts b/@libs/users-backend/src/services/pdf.service.ts new file mode 100644 index 0000000..aa10bbc --- /dev/null +++ b/@libs/users-backend/src/services/pdf.service.ts @@ -0,0 +1,37 @@ +import puppeteer, { Browser } from "puppeteer"; + +export class PdfService { + private browser: Browser | null = null; + + private async getBrowser(): Promise { + if (!this.browser) { + this.browser = await puppeteer.launch({ + headless: true, + }); + } + + return this.browser; + } + + public async renderHtmlToPdf(html: string): Promise { + const browser = await this.getBrowser(); + + const page = await browser.newPage(); + + await page.setContent(html, { + waitUntil: "networkidle0", + }); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }); + + await page.close(); + + return Buffer.from(pdf); + } +} + +export default new PdfService(); diff --git a/@libs/users-backend/src/services/template.service.ts b/@libs/users-backend/src/services/template.service.ts new file mode 100644 index 0000000..811dc9a --- /dev/null +++ b/@libs/users-backend/src/services/template.service.ts @@ -0,0 +1,128 @@ +import * as Handlebars from "handlebars"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import type { SectionEntityType } from "#src/entities/sections.entity.ts"; + +export const SectionItemSchema = z.object({ + jsonData: z.record(z.string(), z.string()), +}); + +export const SectionSchema = z.object({ + templateId: z.string(), + title: z.string(), + position: z.number(), + items: z.array(SectionItemSchema), +}); + +export const SectionsSchema = z.array(SectionSchema); + +const TEMPLATE_BASE_URL = "/templates/"; + +const SIDEBAR_TEMPLATE_IDS = new Set([ + "e2e-section-template-informations-personnelles", + "e2e-section-template-compétences", + "e2e-section-template-langues", +]); + +async function fetchTemplate(pathUrl: string): Promise { + const filePath = path.join(process.cwd(), "src", pathUrl); + + try { + return await fs.readFile(filePath, "utf-8"); + } catch { + return null; + } +} + +function templateIdToFilename(templateId: string): string { + const filename = templateId.replace(/^e2e-section-template-/, ""); + return filename; +} + +async function renderSection(section: SectionEntityType, modelId: string): Promise { + const { + template: { id: templateId }, + title, + items, + } = section; + + const filename = templateIdToFilename(templateId); + const templatePath = `${TEMPLATE_BASE_URL}${modelId}/sections/${filename}.html`; + const templateSource = await fetchTemplate(templatePath); + + if (!templateSource) return ""; + + const compiledTemplate = Handlebars.compile(templateSource); + + if (templateId === "e2e-section-template-informations-personnelles") { + const item = items[0] as { jsonData: Record } | undefined; + if (!item) return ""; + + const pdpPath = item.jsonData["profilePicture"]; + let resolvedProfilePicture = pdpPath; + + if (pdpPath) { + try { + const pdpBuffer = await fs.readFile(pdpPath); + resolvedProfilePicture = `data:image/png;base64,${pdpBuffer.toString("base64")}`; + } catch { + resolvedProfilePicture = ""; + } + } + + const jsonData = { ...item.jsonData, profilePicture: resolvedProfilePicture }; + return compiledTemplate({ + title, + item: jsonData, + items: [jsonData], + }); + } + + const context = + items.length === 1 + ? { title, item: items[0]?.jsonData, items: items.map((i) => i.jsonData) } + : { title, items: items.map((i) => i.jsonData) }; + + return compiledTemplate(context); +} + +export default async function renderCv( + sections: SectionEntityType[], + modelId: string, +): Promise { + const sorted = [...sections].sort((a, b) => a.position - b.position); + + const leftSections: { html: string }[] = []; + const rightSections: { html: string }[] = []; + + for (const section of sorted) { + if (!section.isActive) continue; + const html = await renderSection(section, modelId); + + if (!html) continue; + + if (SIDEBAR_TEMPLATE_IDS.has(section.template.id)) { + leftSections.push({ html }); + } else { + rightSections.push({ html }); + } + } + + const [baseSource, styles] = await Promise.all([ + fetchTemplate(`${TEMPLATE_BASE_URL}${modelId}/base.html`), + fetchTemplate(`${TEMPLATE_BASE_URL}${modelId}/style.css`), + ]); + + if (!baseSource || !styles) { + return ""; + } + + const compiled = Handlebars.compile(baseSource); + + return compiled({ + leftSections, + rightSections, + styles, + }); +} diff --git a/@libs/users-backend/tests/integration/sections/update.route.test.ts b/@libs/users-backend/tests/integration/sections/update.route.test.ts index fd21c9c..69106dd 100644 --- a/@libs/users-backend/tests/integration/sections/update.route.test.ts +++ b/@libs/users-backend/tests/integration/sections/update.route.test.ts @@ -44,7 +44,7 @@ test("UpdateRoute returns 200 and updates a section", async () => { authorization: module.generateBearerToken(TestModule.TEST_USER_ID), }, payload: { - title: "Updated Test Section", + isActive: true, }, }); @@ -57,7 +57,7 @@ test("UpdateRoute returns 200 and updates a section", async () => { attributes: { curriculumId: TestModule.TEST_CURRICULUM_ID, templateId: TestModule.TEST_SECTION_TEMPLATE_ID, - title: "Updated Test Section", + title: "Test Section", position: 0, }, }); @@ -68,7 +68,7 @@ test("UpdateRoute returns 200 and updates a section", async () => { { refresh: true }, ); expect(section).not.toBeNull(); - expect(section!.title).toBe("Updated Test Section"); + expect(section!.title).toBe("Test Section"); }); test("UpdateRoute returns 404 if curriculum not found", async () => { @@ -97,7 +97,7 @@ test("UpdateRoute returns 404 if curriculum not found", async () => { authorization: module.generateBearerToken(TestModule.TEST_USER_ID), }, payload: { - title: "Updated Test Section", + isActive: true, }, }); @@ -126,7 +126,7 @@ test("UpdateRoute returns 404 if curriculum does not belong to user", async () = authorization: module.generateBearerToken(TestModule.TEST_USER_ID), }, payload: { - title: "Updated Test Section", + isActive: true, }, }); @@ -160,7 +160,7 @@ test("UpdateRoute returns 404 if section not found", async () => { authorization: module.generateBearerToken(TestModule.TEST_USER_ID), }, payload: { - title: "Updated Test Section", + isActive: true, }, }); @@ -209,7 +209,7 @@ test("UpdateRoute returns 400 if title is missing", async () => { expect(body.errors[0]).toMatchObject({ status: "400", title: "Validation Error", - detail: "Invalid input: expected string, received undefined", + detail: "Invalid input: expected boolean, received undefined", }); }); @@ -218,7 +218,7 @@ test("UpdateRoute returns 401 when not authenticated", async () => { method: "PATCH", url: `/curriculums/${TestModule.TEST_CURRICULUM_ID}/sections/${TestModule.TEST_SECTION_ID}`, payload: { - title: "Updated Test Section", + isActive: true, }, }); diff --git a/@libs/users-backend/tests/utils/setup-module.ts b/@libs/users-backend/tests/utils/setup-module.ts index c98460d..3ddbdf7 100644 --- a/@libs/users-backend/tests/utils/setup-module.ts +++ b/@libs/users-backend/tests/utils/setup-module.ts @@ -192,6 +192,7 @@ export class TestModule { template: data.templateId, title: data.title, position: 0, + isActive: true, }); } diff --git a/@libs/users-front/package.json b/@libs/users-front/package.json index 2b8bed1..4cc8ded 100644 --- a/@libs/users-front/package.json +++ b/@libs/users-front/package.json @@ -65,6 +65,7 @@ "ember-cli-page-object": "catalog:", "ember-click-outside": "^6.1.1", "ember-click-outside-modifier": "^4.1.2", + "ember-drag-drop": "^1.0.1", "ember-immer-changeset": "catalog:", "ember-intl": "catalog:", "ember-simple-auth": "catalog:", @@ -161,6 +162,7 @@ "./routes/login.js": "./dist/_app_/routes/login.js", "./routes/logout.js": "./dist/_app_/routes/logout.js", "./schemas/curriculums.js": "./dist/_app_/schemas/curriculums.js", + "./schemas/models.js": "./dist/_app_/schemas/models.js", "./schemas/section-items.js": "./dist/_app_/schemas/section-items.js", "./schemas/section-templates.js": "./dist/_app_/schemas/section-templates.js", "./schemas/sections.js": "./dist/_app_/schemas/sections.js", diff --git a/@libs/users-front/src/components/curriculums/edit/curriculum-edit-section-item.gts b/@libs/users-front/src/components/curriculums/edit/curriculum-edit-section-item.gts index 51a9d7e..8fe6954 100644 --- a/@libs/users-front/src/components/curriculums/edit/curriculum-edit-section-item.gts +++ b/@libs/users-front/src/components/curriculums/edit/curriculum-edit-section-item.gts @@ -7,7 +7,7 @@ import type { SchemaField } from '#src/schemas/section-templates.ts'; import { fn } from '@ember/helper'; import { service } from '@ember/service'; import type CurriculumService from '#src/services/curriculum.ts'; -import { type IntlService } from 'ember-intl'; +import { t, type IntlService } from 'ember-intl'; interface CurriculumEditSectionItemSignature { Element: HTMLDivElement; @@ -19,11 +19,13 @@ interface CurriculumEditSectionItemSignature { itemId: string; onDelete: () => void; onUpdate: () => void; + onDragStart?: (event: DragEvent) => void; + onDrop?: (event: DragEvent) => void; + onDragEnd?: (event: DragEvent) => void; }; } const isTextarea = (field: SchemaField) => field.type === 'textarea'; - const isFileInput = (field: SchemaField) => field.type === 'file'; class CurriculumEditSectionItem extends Component { @@ -31,6 +33,7 @@ class CurriculumEditSectionItem extends Component
- Choisir un fichier + {{t "curriculums.edit.chooseFile"}} diff --git a/@libs/users-front/src/components/curriculums/edit/curriculum-edit-top-bar.gts b/@libs/users-front/src/components/curriculums/edit/curriculum-edit-top-bar.gts index 9441461..da0293d 100644 --- a/@libs/users-front/src/components/curriculums/edit/curriculum-edit-top-bar.gts +++ b/@libs/users-front/src/components/curriculums/edit/curriculum-edit-top-bar.gts @@ -28,8 +28,23 @@ class CurriculumTopBar extends Component { } @action - handleDownload() { - // TODO: implement curriculum download + async handleDownload() { + try { + const blob = await this.curriculum.export( + this.args.curriculum?.id || '', + this.curriculum.getModel() || '' + ); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.args.curriculum?.title || 'curriculum'}.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error('Export failed', err); + } } @action diff --git a/@libs/users-front/src/components/curriculums/edit/curriculum-edit-view.gts b/@libs/users-front/src/components/curriculums/edit/curriculum-edit-view.gts index 018b665..250413f 100644 --- a/@libs/users-front/src/components/curriculums/edit/curriculum-edit-view.gts +++ b/@libs/users-front/src/components/curriculums/edit/curriculum-edit-view.gts @@ -20,14 +20,16 @@ interface CurriculumEditViewSignature { }; } -const isSectionInTemplate = (sections: Sections[], templateId: string | null) => - sections.some((s) => s.templateId === templateId); +const isSectionActive = (sections: Sections[], templateId: string | null) => + sections.some((s) => s.templateId === templateId && s.isActive); class CurriculumEditView extends Component { @service declare curriculum: CurriculumService; @tracked sectionTemplates: SectionTemplates[] = []; @tracked sections: Sections[] = []; dragSourceIndex: number | null = null; + dragSourceItemIndex: number | null = null; + dragSourceTemplateId: string | null = null; constructor(owner: Owner, args: CurriculumEditViewSignature['Args']) { super(owner, args); @@ -45,14 +47,16 @@ class CurriculumEditView extends Component { ); } - onAddSection = async (templateId: string | null, title: string) => { + onAddSection = async (templateId: string | null) => { if (!templateId) return; - await this.curriculum.createSection( + const sectionId = this.getSectionIdByTemplate(templateId, this.sections); + if (!sectionId) return; + await this.curriculum.updateSection( this.args.curriculumId, - templateId, - title + sectionId, + true ); - await this.loadSections(); + this.args.onUpdate(); }; onAddItem = async (templateId: string | null) => { @@ -85,8 +89,12 @@ class CurriculumEditView extends Component { if (!templateId) return; const sectionId = this.getSectionIdByTemplate(templateId, this.sections); if (!sectionId) return; - await this.curriculum.deleteSection(this.args.curriculumId, sectionId); - await this.loadSections(); + await this.curriculum.updateSection( + this.args.curriculumId, + sectionId, + false + ); + this.args.onUpdate(); }; @action @@ -95,7 +103,7 @@ class CurriculumEditView extends Component { } @action - onDrop(targetIndex: number) { + async onDrop(targetIndex: number) { const sourceIndex = this.dragSourceIndex; if (sourceIndex === null || sourceIndex === targetIndex) return; @@ -104,10 +112,80 @@ class CurriculumEditView extends Component { if (!moved) return; reordered.splice(targetIndex, 0, moved); - // TODO : await this.curriculum.updateOrderSections(this.args.curriculumId, reordered); + const newOrder = reordered.map((t) => + this.getSectionIdByTemplate(t.id, this.sections) + ); + + if (newOrder.some((id) => typeof id !== 'string')) return; + await this.curriculum.updateOrderSections( + this.args.curriculumId, + newOrder as string[] + ); this.sectionTemplates = reordered; this.dragSourceIndex = null; + + this.args.onUpdate(); + } + + @action + async onItemDrop(templateId: string | null, targetIndex: number) { + if (!templateId) return; + const sourceIndex = this.dragSourceItemIndex; + if ( + sourceIndex === null || + sourceIndex === targetIndex || + this.dragSourceTemplateId !== templateId + ) + return; + + const sectionId = this.getSectionIdByTemplate(templateId, this.sections); + if (!sectionId) return; + + const items = this.getItemsByTemplate(templateId, this.sections); + const reordered = [...items]; + const [moved] = reordered.splice(sourceIndex, 1); + if (!moved) return; + reordered.splice(targetIndex, 0, moved); + + const newOrder = reordered.map((i) => i.id); + + await this.curriculum.updateOrderItems( + this.args.curriculumId, + sectionId, + newOrder + ); + + this.dragSourceItemIndex = null; + this.dragSourceTemplateId = null; + + await this.loadSections(); + this.args.onUpdate(); + } + + @action + onItemDragEnd() { + this.dragSourceItemIndex = null; + this.dragSourceTemplateId = null; + } + + @action + onItemDragStart(templateId: string | null, itemIndex: number) { + if (!templateId) return; + this.dragSourceItemIndex = itemIndex; + this.dragSourceTemplateId = templateId; + } + + get sortedSectionTemplates() { + return this.sectionTemplates.slice().sort((a, b) => { + const sectionA = this.sections.find((s) => s.templateId === a.id); + const sectionB = this.sections.find((s) => s.templateId === b.id); + + const positionA = sectionA?.position ?? Infinity; + const positionB = sectionB?.position ?? Infinity; + + return positionA - positionB; + }); } @action @@ -115,13 +193,17 @@ class CurriculumEditView extends Component { this.dragSourceIndex = null; } + getTemplateLabel(templateId: string | null) { + return this.sectionTemplates.find((t) => t.id === templateId)?.label || ''; + } +