feat: initialisation complète du projet CESIZen

- Backend Laravel 11 (API REST, JWT auth, PostgreSQL)
- Frontend Next.js 14 (React, TypeScript, Tailwind CSS)
- Docker Compose (PostgreSQL, backend, frontend)
- Modules : auth, profil, tracker émotions, informations, admin
This commit is contained in:
Azmog
2026-03-11 16:27:01 +01:00
commit e3da625927
158 changed files with 25139 additions and 0 deletions
@@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, ArrowRight, Check } from 'lucide-react';
import { useCreateSaisie } from '@/hooks/useTracker';
import EmotionPicker from '@/components/features/EmotionPicker';
import Slider from '@/components/ui/Slider';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import toast from 'react-hot-toast';
const STEPS = [
{ id: 1, title: 'Émotion', description: 'Comment vous sentez-vous ?' },
{ id: 2, title: 'Intensité', description: 'À quel point ressentez-vous cette émotion ?' },
{ id: 3, title: 'Note', description: 'Souhaitez-vous ajouter un commentaire ?' },
];
export default function NouvelleSaisiePage() {
const router = useRouter();
const createSaisie = useCreateSaisie();
const [step, setStep] = useState(1);
const [emotionId, setEmotionId] = useState<number | undefined>();
const [intensite, setIntensite] = useState(5);
const [note, setNote] = useState('');
const [dateSaisie, setDateSaisie] = useState(
new Date().toISOString().split('T')[0]
);
const canNext = () => {
if (step === 1) return !!emotionId;
return true;
};
const handleSubmit = async () => {
if (!emotionId) return;
try {
await createSaisie.mutateAsync({
emotion_id: emotionId,
intensite,
note: note.trim() || undefined,
date_saisie: dateSaisie,
});
toast.success('Saisie enregistrée !');
router.push('/journal');
} catch {
toast.error('Erreur lors de l\'enregistrement');
}
};
return (
<div className="max-w-2xl mx-auto">
<button
onClick={() => router.back()}
className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-6 transition-colors"
>
<ArrowLeft size={16} />
Retour
</button>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2">
Nouvelle saisie
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-8">
Enregistrez votre émotion en quelques étapes simples.
</p>
{/* Stepper */}
<div className="flex items-center gap-2 mb-8">
{STEPS.map((s) => (
<div key={s.id} className="flex items-center gap-2 flex-1">
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold shrink-0
${step > s.id
? 'bg-malachite-500 text-white'
: step === s.id
? 'bg-candlelight-500 text-black'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}
`}
>
{step > s.id ? <Check size={16} /> : s.id}
</div>
{s.id < STEPS.length && (
<div
className={`flex-1 h-0.5 ${
step > s.id ? 'bg-malachite-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</div>
))}
</div>
{/* Étape actuelle */}
<Card className="mb-6">
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-1">
{STEPS[step - 1].title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{STEPS[step - 1].description}
</p>
{step === 1 && (
<EmotionPicker value={emotionId} onChange={setEmotionId} />
)}
{step === 2 && (
<div className="space-y-6">
<Slider
value={intensite}
onChange={setIntensite}
min={1}
max={10}
label="Intensité de l'émotion"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Faible</span>
<span>Modérée</span>
<span>Forte</span>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Date de la saisie
</label>
<input
type="date"
value={dateSaisie}
onChange={(e) => setDateSaisie(e.target.value)}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Note (optionnel)
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..."
rows={4}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-candlelight-500 placeholder:text-gray-400 resize-none"
/>
</div>
</div>
)}
</Card>
{/* Navigation */}
<div className="flex justify-between">
<Button
variant="ghost"
onClick={() => setStep(step - 1)}
disabled={step === 1}
>
<ArrowLeft size={16} />
Précédent
</Button>
{step < 3 ? (
<Button
onClick={() => setStep(step + 1)}
disabled={!canNext()}
>
Suivant
<ArrowRight size={16} />
</Button>
) : (
<Button
onClick={handleSubmit}
loading={createSaisie.isPending}
disabled={!emotionId}
>
<Check size={16} />
Enregistrer
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Plus, Filter, Calendar } from 'lucide-react';
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
import { useEmotions } from '@/hooks/useEmotions';
import TrackerTimeline from '@/components/features/TrackerTimeline';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Skeleton from '@/components/ui/Skeleton';
import toast from 'react-hot-toast';
export default function JournalPage() {
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<{
date_debut?: string;
date_fin?: string;
emotion_id?: number;
}>({});
const { data: saisies, isLoading } = useSaisies(filters);
const { data: emotions } = useEmotions();
const deleteSaisie = useDeleteSaisie();
const handleDelete = async (id: number) => {
if (!confirm('Supprimer cette saisie ?')) return;
try {
await deleteSaisie.mutateAsync(id);
toast.success('Saisie supprimée');
} catch {
toast.error('Erreur lors de la suppression');
}
};
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
Mon journal
</h1>
<p className="text-gray-500 dark:text-gray-400">
Historique de vos émotions au quotidien
</p>
</div>
<div className="flex gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={16} />
Filtrer
</Button>
<Link href="/journal/nouvelle-saisie">
<Button size="sm">
<Plus size={16} />
Nouvelle saisie
</Button>
</Link>
</div>
</div>
{/* Filtres */}
{showFilters && (
<Card className="mb-6">
<div className="grid sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Date début
</label>
<input
type="date"
value={filters.date_debut || ''}
onChange={(e) => setFilters({ ...filters, date_debut: e.target.value || undefined })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Date fin
</label>
<input
type="date"
value={filters.date_fin || ''}
onChange={(e) => setFilters({ ...filters, date_fin: e.target.value || undefined })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Émotion
</label>
<select
value={filters.emotion_id || ''}
onChange={(e) => setFilters({ ...filters, emotion_id: e.target.value ? Number(e.target.value) : undefined })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
>
<option value="">Toutes</option>
{emotions?.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.icone} {emotion.nom}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFilters({})}
>
Réinitialiser
</Button>
</div>
</Card>
)}
{/* Timeline */}
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 bg-white dark:bg-gray-900 rounded-xl">
<Skeleton variant="circle" className="w-12 h-12" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-3 w-48" />
</div>
</div>
))}
</div>
) : (
<TrackerTimeline
saisies={saisies || []}
onDelete={handleDelete}
/>
)}
</div>
);
}
@@ -0,0 +1,191 @@
'use client';
import { useState } from 'react';
import { useRapport } from '@/hooks/useRapport';
import EmotionChart from '@/components/features/EmotionChart';
import Card from '@/components/ui/Card';
import Skeleton from '@/components/ui/Skeleton';
import Badge from '@/components/ui/Badge';
import { BarChart3, TrendingUp, PieChart } from 'lucide-react';
type Period = 'week' | 'month' | 'quarter' | 'year';
const PERIODS: { value: Period; label: string }[] = [
{ value: 'week', label: 'Semaine' },
{ value: 'month', label: 'Mois' },
{ value: 'quarter', label: 'Trimestre' },
{ value: 'year', label: 'Année' },
];
export default function RapportsPage() {
const [period, setPeriod] = useState<Period>('month');
const { data: rapport, isLoading } = useRapport(period);
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
Mes rapports
</h1>
<p className="text-gray-500 dark:text-gray-400">
Analysez vos tendances émotionnelles
</p>
</div>
{/* Sélecteur de période */}
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
{PERIODS.map((p) => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
${period === p.value
? 'bg-white dark:bg-gray-700 shadow-sm text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}
`}
>
{p.label}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="space-y-6">
<div className="grid sm:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-8 w-16" />
</Card>
))}
</div>
<Card>
<Skeleton variant="rect" className="h-80" />
</Card>
</div>
) : rapport ? (
<div className="space-y-6">
{/* Stats résumées */}
<div className="grid sm:grid-cols-3 gap-4">
<Card>
<div className="flex items-center gap-3 mb-2">
<BarChart3 size={18} className="text-candlelight-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">Total saisies</p>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport.stats.total_saisies}
</p>
<p className="text-xs text-gray-400 mt-1">
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au {new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
</p>
</Card>
<Card>
<div className="flex items-center gap-3 mb-2">
<TrendingUp size={18} className="text-malachite-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">Intensité moyenne</p>
</div>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
</p>
<span className="text-sm text-gray-400">/10</span>
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-2">
<PieChart size={18} className="text-blue-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">Émotion dominante</p>
</div>
{rapport.stats.emotion_dominante ? (
<div>
<Badge color={rapport.stats.emotion_dominante.couleur}>
{rapport.stats.emotion_dominante.nom}
</Badge>
<p className="text-xs text-gray-400 mt-1">
{rapport.stats.emotion_dominante.count} fois
</p>
</div>
) : (
<p className="text-xl text-gray-300 dark:text-gray-600">—</p>
)}
</Card>
</div>
{/* Répartition (Camembert) */}
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Répartition des émotions
</h2>
<EmotionChart data={rapport} type="repartition" />
</Card>
{/* Évolution (Lignes) */}
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Évolution dans le temps
</h2>
<EmotionChart data={rapport} type="evolution" />
</Card>
{/* Intensité moyenne (Barres) */}
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Intensité moyenne par période
</h2>
<EmotionChart data={rapport} type="intensite" />
</Card>
{/* Détail répartition */}
{rapport.repartition.length > 0 && (
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Détail par émotion
</h2>
<div className="space-y-3">
{rapport.repartition.map((item) => (
<div key={item.nom} className="flex items-center gap-4">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: item.couleur }}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 w-32">
{item.nom}
</span>
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${item.pourcentage}%`,
backgroundColor: item.couleur,
}}
/>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400 w-16 text-right">
{item.pourcentage}%
</span>
<span className="text-xs text-gray-400 w-20 text-right">
Moy: {item.intensite_moyenne.toFixed(1)}/10
</span>
</div>
))}
</div>
</Card>
)}
</div>
) : (
<Card className="text-center py-16">
<BarChart3 size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-lg text-gray-500 dark:text-gray-400">
Pas de données pour cette période
</p>
</Card>
)}
</div>
);
}