feat(app) : phase 1 — registre, profils, moteur de recommandation, export markdown
- Schémas Zod partagés ($lib/schemas) : modèle, profil (PRD §4), null = « à vérifier », sources[] obligatoires à chaque écriture - Persistance YAML dans DATA_DIR + commits git automatiques (simple-git) ; seeds PRD au premier démarrage (1 modèle §4.1, 5 profils, 2 templates) - CRUD registre et profils : pages liste/édition/création, form actions, champs tri-état, messages d'erreur Zod, commit par écriture - Moteur de recommandation pur et testé (12 cas : nominal, licence null, seuil, statut mort, vram, modalité, fraîcheur, pénalités) : filtrage dur (requiert/exclut/obligatoire/mort) puis scoring pondéré (préférences, rang arena, fraîcheur, pénalités statut et champs null), justification et warnings (attribution, seuil, garde-fous, déclarations plateformes) - POST /api/recommander (SSO requis) + page /recommander - Exporteur markdown /exports : templates éditables, génération depuis le registre (tableaux registre + classements), commit par document - Vérifié : check/lint/test (22) /build verts, test fumée HTTP complet (seed + git init, CRUD par form action, exports générés, filtres API, 401/404), svelte-autofixer propre sur les 11 composants
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { lireProfils } from '$lib/server/stockage';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const profils = await lireProfils();
|
||||
return { profils };
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.profils.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="titre-page">
|
||||
<h1>{fr.profils.titre}</h1>
|
||||
<a class="bouton" href={resolve('/profils/nouveau')}>{fr.profils.nouveau}</a>
|
||||
</div>
|
||||
|
||||
{#if data.profils.length === 0}
|
||||
<p>{fr.profils.aucun}</p>
|
||||
{:else}
|
||||
<div class="grille">
|
||||
{#each data.profils as profil (profil.id)}
|
||||
<article class="carte">
|
||||
<h2>{profil.nom}</h2>
|
||||
{#if profil.requiert.length > 0}
|
||||
<p>
|
||||
<strong>{fr.profils.legende_requiert} :</strong>
|
||||
{profil.requiert.map((c) => fr.formulaires.cles[c]).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if profil.exclut.length > 0}
|
||||
<p>
|
||||
<strong>{fr.profils.legende_exclut} :</strong>
|
||||
{profil.exclut.map((c) => fr.formulaires.cles[c]).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if profil.contraintes_specifiques}
|
||||
<p class="contraintes">{profil.contraintes_specifiques}</p>
|
||||
{/if}
|
||||
<a href={resolve(`/profils/${profil.id}`)}>{fr.profils.modifier}</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.titre-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.carte h2 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.carte p {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.contraintes {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { profilSchema } from '$lib/schemas';
|
||||
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const profils = await lireProfils();
|
||||
const profil = profils.find((p) => p.id === params.id);
|
||||
if (!profil) error(404, 'Profil introuvable');
|
||||
return { profil };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enregistrer: async ({ request, params }) => {
|
||||
const data = await request.formData();
|
||||
const brut = profilDepuisFormulaire(data);
|
||||
// L'identifiant d'un profil existant n'est pas modifiable (champ readonly).
|
||||
const resultat = profilSchema.safeParse({ ...(brut as object), id: params.id });
|
||||
if (!resultat.success) {
|
||||
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||
}
|
||||
|
||||
const profils = await lireProfils();
|
||||
const index = profils.findIndex((p) => p.id === params.id);
|
||||
if (index === -1) error(404, 'Profil introuvable');
|
||||
|
||||
profils[index] = resultat.data;
|
||||
await ecrireProfils(profils, `feat : mise à jour du profil ${params.id}`);
|
||||
redirect(303, '/profils');
|
||||
},
|
||||
|
||||
supprimer: async ({ params }) => {
|
||||
const profils = await lireProfils();
|
||||
const restant = profils.filter((p) => p.id !== params.id);
|
||||
if (restant.length === profils.length) error(404, 'Profil introuvable');
|
||||
|
||||
await ecrireProfils(restant, `feat : suppression du profil ${params.id}`);
|
||||
redirect(303, '/profils');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import ProfilForm from '$lib/components/ProfilForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.profils.titre_edition} : {data.profil.nom} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.profils.titre_edition} : {data.profil.nom}</h1>
|
||||
|
||||
<ProfilForm
|
||||
profil={data.profil}
|
||||
edition
|
||||
actionSupprimer="?/supprimer"
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
/>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { profilSchema } from '$lib/schemas';
|
||||
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const brut = profilDepuisFormulaire(data);
|
||||
const resultat = profilSchema.safeParse(brut);
|
||||
if (!resultat.success) {
|
||||
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||
}
|
||||
|
||||
const profils = await lireProfils();
|
||||
if (profils.some((p) => p.id === resultat.data.id)) {
|
||||
return fail(409, {
|
||||
erreurs: [`Un profil avec l'identifiant « ${resultat.data.id} » existe déjà.`],
|
||||
brut
|
||||
});
|
||||
}
|
||||
|
||||
await ecrireProfils([...profils, resultat.data], `feat : ajout du profil ${resultat.data.id}`);
|
||||
redirect(303, '/profils');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import ProfilForm from '$lib/components/ProfilForm.svelte';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.profils.titre_nouveau} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.profils.titre_nouveau}</h1>
|
||||
|
||||
<ProfilForm
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
/>
|
||||
Reference in New Issue
Block a user