Refactoring AI candidate analysis: UI improvements, data normalization, provider management and real-time score clamping

This commit is contained in:
jeremy bayse
2026-04-21 06:41:37 +02:00
parent abfe01190b
commit 2216de1a02
19 changed files with 1793 additions and 1402 deletions

View File

@@ -0,0 +1,35 @@
<script setup>
/**
* RqBadge.vue — Badge de statut candidat
*
* @prop status 'en_attente' | 'en_cours' | 'termine' | 'refuse'
* @prop label Override du texte affiché (optionnel)
*/
const props = defineProps({
status: { type: String, required: true },
label: { type: String, default: null },
});
const labels = {
en_attente: 'En attente',
en_cours: 'En cours',
termine: 'Terminé',
refuse: 'Refusé',
};
const displayLabel = props.label ?? labels[props.status] ?? props.status;
</script>
<template>
<span
:class="[
'inline-block px-2.5 py-0.5 rounded-full text-2xs font-black uppercase tracking-[0.12em]',
status === 'en_attente' && 'bg-ink/5 text-ink/55',
status === 'en_cours' && 'bg-primary/10 text-primary',
status === 'termine' && 'bg-success/10 text-success',
status === 'refuse' && 'bg-accent/10 text-accent',
]"
>
{{ displayLabel }}
</span>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
/**
* RqButton.vue — Bouton principal RecruitQuizz
*
* @prop variant 'primary' | 'ghost' | 'danger' | 'outline'
* @prop size 'sm' | 'md' | 'lg'
* @prop disabled Boolean
* @prop loading Boolean — affiche un spinner
* @prop icon SVG path string optionnel (gauche du label)
*/
defineProps({
variant: { type: String, default: 'primary' },
size: { type: String, default: 'md' },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
});
</script>
<template>
<button
v-bind="$attrs"
:disabled="disabled || loading"
:class="[
// Base
'inline-flex items-center justify-center gap-1.5 font-sans font-extrabold uppercase tracking-[0.08em] transition-all duration-150 select-none focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
// Size
size === 'sm' && 'text-2xs px-3.5 py-2 rounded-lg',
size === 'md' && 'text-xs px-5 py-2.5 rounded-[10px]',
size === 'lg' && 'text-sm px-7 py-3.5 rounded-[10px]',
// Variant
variant === 'primary' && 'bg-highlight text-highlight-dark shadow-gold hover:brightness-105 hover:-translate-y-px hover:shadow-lg focus:ring-highlight/50',
variant === 'ghost' && 'bg-transparent text-ink border border-ink/10 hover:bg-primary/5 hover:border-primary hover:text-primary focus:ring-primary/30',
variant === 'outline' && 'bg-transparent text-primary border-2 border-primary hover:bg-primary hover:text-white focus:ring-primary/30',
variant === 'danger' && 'bg-accent/10 text-accent border border-accent/20 hover:bg-accent hover:text-white focus:ring-accent/30',
]"
>
<!-- Spinner -->
<svg v-if="loading" class="animate-spin h-4 w-4 opacity-70" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<slot />
</button>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
/**
* RqCard.vue — Carte conteneur RecruitQuizz
*
* @prop padding 'none' | 'sm' | 'md' | 'lg'
* @prop hover Boolean — active l'effet de survol (lift)
* @prop accent 'primary' | 'gold' | 'green' | 'red' — bordure colorée en bas
*/
defineProps({
padding: { type: String, default: 'md' },
hover: { type: Boolean, default: false },
accent: { type: String, default: null },
});
</script>
<template>
<div
:class="[
'bg-surface rounded-2xl border border-ink/[0.07] shadow-sm',
hover && 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
accent === 'primary' && 'border-b-2 border-b-primary',
accent === 'gold' && 'border-b-2 border-b-highlight',
accent === 'green' && 'border-b-2 border-b-success',
accent === 'red' && 'border-b-2 border-b-accent',
padding === 'none' && 'overflow-hidden',
padding === 'sm' && 'p-4',
padding === 'md' && 'p-5',
padding === 'lg' && 'p-6',
]"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
/**
* RqInput.vue — Champ de saisie RecruitQuizz
*
* @prop modelValue String (v-model)
* @prop placeholder String
* @prop type String (text, email, password, search…)
* @prop icon 'search' | 'mail' | 'lock' — icône préfixe
* @prop error String — message d'erreur
* @prop label String — label au-dessus du champ
*/
import { computed } from 'vue';
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
type: { type: String, default: 'text' },
icon: { type: String, default: null },
error: { type: String, default: null },
label: { type: String, default: null },
});
const emit = defineEmits(['update:modelValue']);
const iconPaths = {
search: 'M11 17.25a6.25 6.25 0 110-12.5 6.25 6.25 0 010 12.5zM16 16l4.5 4.5',
mail: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zM22 6l-10 7L2 6',
lock: 'M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4',
};
</script>
<template>
<div class="flex flex-col gap-1.5 w-full">
<!-- Label -->
<label v-if="label" class="text-2xs font-black uppercase tracking-[0.14em] text-ink/50">
{{ label }}
</label>
<!-- Input wrapper -->
<div class="relative">
<!-- Icon -->
<span v-if="icon" class="absolute left-3 top-1/2 -translate-y-1/2 text-ink/30 pointer-events-none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="iconPaths[icon]" />
</svg>
</span>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
@input="emit('update:modelValue', $event.target.value)"
:class="[
'w-full rounded-[10px] border bg-surface font-sans text-sm font-semibold text-ink placeholder:text-ink/30 outline-none transition-all duration-150',
'focus:border-primary focus:ring-2 focus:ring-primary/15',
icon ? 'pl-9 pr-4 py-2.5' : 'px-4 py-2.5',
error ? 'border-accent focus:border-accent focus:ring-accent/15' : 'border-ink/10',
]"
/>
</div>
<!-- Error -->
<p v-if="error" class="text-2xs font-bold text-accent">{{ error }}</p>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
/**
* RqScoreBar.vue — Barre de score pondéré
*
* @prop value Number — score actuel
* @prop max Number — score maximum (défaut 20)
* @prop showLabel Boolean — affiche "X/max" à droite
*/
import { computed } from 'vue';
const props = defineProps({
value: { type: Number, required: true },
max: { type: Number, default: 20 },
showLabel: { type: Boolean, default: true },
});
const pct = computed(() => Math.min(100, (props.value / props.max) * 100));
const colorClass = computed(() => {
if (pct.value >= 80) return 'bg-success';
if (pct.value >= 60) return 'bg-highlight';
return 'bg-accent';
});
</script>
<template>
<div class="flex items-center gap-2.5 w-full">
<div class="flex-1 h-[5px] bg-ink/[0.07] rounded-full overflow-hidden">
<div
:class="['h-full rounded-full transition-all duration-500', colorClass]"
:style="{ width: `${pct}%` }"
/>
</div>
<span v-if="showLabel" class="text-xs font-black text-ink tabular-nums min-w-[44px] text-right">
{{ value }}<span class="text-[9px] text-ink/40 font-semibold">/{{ max }}</span>
</span>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
/**
* RqStatCard.vue — Carte KPI dashboard admin
*
* @prop label String — libellé (ex: "Total Candidats")
* @prop value String | Number — valeur principale
* @prop sub String — sous-texte (ex: "+3 ce mois")
* @prop color 'primary' | 'gold' | 'green' | 'sky' | 'red'
* @prop unit String — unité affichée après la valeur (ex: "/ 20")
*/
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
sub: { type: String, default: null },
color: { type: String, default: 'primary' },
unit: { type: String, default: null },
});
const colorMap = {
primary: { text: 'text-primary', glow: 'from-primary/10' },
gold: { text: 'text-highlight', glow: 'from-highlight/15' },
green: { text: 'text-success', glow: 'from-success/10' },
sky: { text: 'text-primary-light', glow: 'from-primary-light/10' },
red: { text: 'text-accent', glow: 'from-accent/10' },
};
const c = computed(() => colorMap[props.color] ?? colorMap.primary);
</script>
<template>
<div class="relative bg-surface rounded-2xl border border-ink/[0.07] shadow-sm p-5 overflow-hidden
transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md group cursor-default">
<!-- Label -->
<p class="text-2xs font-black uppercase tracking-[0.18em] text-ink/40 mb-2.5">
{{ label }}
</p>
<!-- Value -->
<p :class="['font-serif font-black leading-none', c.text]"
style="font-size: clamp(1.75rem, 2.5vw, 2.25rem);">
{{ value }}
<span v-if="unit" class="text-base font-sans font-bold text-ink/30 ml-1">{{ unit }}</span>
</p>
<!-- Sub -->
<p v-if="sub" class="mt-1.5 text-2xs font-semibold text-ink/40">{{ sub }}</p>
<!-- Decorative blob -->
<div :class="['absolute bottom-0 right-0 w-20 h-20 rounded-full bg-gradient-radial to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500', c.glow]" />
</div>
</template>