feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience
This commit is contained in:
110
src/app/motivators/[id]/EditableTitle.tsx
Normal file
110
src/app/motivators/[id]/EditableTitle.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition, useRef, useEffect } from 'react';
|
||||
import { updateMotivatorSession } from '@/actions/moving-motivators';
|
||||
|
||||
interface EditableMotivatorTitleProps {
|
||||
sessionId: string;
|
||||
initialTitle: string;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export function EditableMotivatorTitle({
|
||||
sessionId,
|
||||
initialTitle,
|
||||
isOwner,
|
||||
}: EditableMotivatorTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// Update local state when prop changes (e.g., from SSE)
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setTitle(initialTitle);
|
||||
}
|
||||
}, [initialTitle, isEditing]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title.trim()) {
|
||||
setTitle(initialTitle);
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.trim() === initialTitle) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateMotivatorSession(sessionId, { title: title.trim() });
|
||||
if (!result.success) {
|
||||
setTitle(initialTitle);
|
||||
console.error(result.error);
|
||||
}
|
||||
setIsEditing(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setTitle(initialTitle);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isPending}
|
||||
className="w-full max-w-md rounded-lg border border-border bg-input px-3 py-1.5 text-3xl font-bold text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="group flex items-center gap-2 text-left"
|
||||
title="Cliquez pour modifier"
|
||||
>
|
||||
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
|
||||
<svg
|
||||
className="h-5 w-5 text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
88
src/app/motivators/[id]/page.tsx
Normal file
88
src/app/motivators/[id]/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getMotivatorSessionById } from '@/services/moving-motivators';
|
||||
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { EditableMotivatorTitle } from './EditableTitle';
|
||||
|
||||
interface MotivatorSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function MotivatorSessionPage({ params }: MotivatorSessionPageProps) {
|
||||
const { id } = await params;
|
||||
const authSession = await auth();
|
||||
|
||||
if (!authSession?.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await getMotivatorSessionById(id, authSession.user.id);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||
<Link href="/motivators" className="hover:text-foreground">
|
||||
Moving Motivators
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableMotivatorTitle
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
/>
|
||||
<p className="mt-1 text-lg text-muted">
|
||||
👤 {session.participant}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">
|
||||
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
|
||||
</Badge>
|
||||
<span className="text-sm text-muted">
|
||||
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<MotivatorLiveWrapper
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
currentUserId={authSession.user.id}
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
>
|
||||
<MotivatorBoard
|
||||
sessionId={session.id}
|
||||
cards={session.cards}
|
||||
canEdit={session.canEdit}
|
||||
/>
|
||||
</MotivatorLiveWrapper>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
102
src/app/motivators/new/page.tsx
Normal file
102
src/app/motivators/new/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from '@/components/ui';
|
||||
import { createMotivatorSession } from '@/actions/moving-motivators';
|
||||
|
||||
export default function NewMotivatorSessionPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const title = formData.get('title') as string;
|
||||
const participant = formData.get('participant') as string;
|
||||
|
||||
if (!title || !participant) {
|
||||
setError('Veuillez remplir tous les champs');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createMotivatorSession({ title, participant });
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Une erreur est survenue');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/motivators/${result.data?.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>🎯</span>
|
||||
Nouvelle Session Moving Motivators
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Créez une session pour explorer les motivations intrinsèques d'un collaborateur
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Titre de la session"
|
||||
name="title"
|
||||
placeholder="Ex: Entretien motivation Q1 2025"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nom du participant"
|
||||
name="participant"
|
||||
placeholder="Ex: Jean Dupont"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
|
||||
<li>Classez les 10 cartes de motivation par ordre d'importance</li>
|
||||
<li>Évaluez l'influence positive ou négative de chaque motivation</li>
|
||||
<li>Découvrez le récapitulatif des motivations clés</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" loading={loading} className="flex-1">
|
||||
Créer la session
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
135
src/app/motivators/page.tsx
Normal file
135
src/app/motivators/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
||||
import { Card, CardContent, Badge, Button } from '@/components/ui';
|
||||
|
||||
export default async function MotivatorsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessions = await getMotivatorSessionsByUserId(session.user.id);
|
||||
|
||||
// Separate owned vs shared sessions
|
||||
const ownedSessions = sessions.filter((s) => s.isOwner);
|
||||
const sharedSessions = sessions.filter((s) => !s.isOwner);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Moving Motivators</h1>
|
||||
<p className="mt-1 text-muted">
|
||||
Découvrez ce qui motive vraiment vos collaborateurs
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/motivators/new">
|
||||
<Button>
|
||||
<span>🎯</span>
|
||||
Nouvelle Session
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Sessions Grid */}
|
||||
{sessions.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="text-5xl mb-4">🎯</div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
Aucune session pour le moment
|
||||
</h2>
|
||||
<p className="text-muted mb-6">
|
||||
Créez votre première session Moving Motivators pour explorer les motivations
|
||||
intrinsèques de vos collaborateurs.
|
||||
</p>
|
||||
<Link href="/motivators/new">
|
||||
<Button>Créer ma première session</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* My Sessions */}
|
||||
{ownedSessions.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
📁 Mes sessions ({ownedSessions.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{ownedSessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Shared Sessions */}
|
||||
{sharedSessions.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
🤝 Sessions partagées avec moi ({sharedSessions.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sharedSessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionWithMeta = Awaited<ReturnType<typeof getMotivatorSessionsByUserId>>[number];
|
||||
|
||||
function SessionCard({ session: s }: { session: SessionWithMeta }) {
|
||||
return (
|
||||
<Link href={`/motivators/${s.id}`}>
|
||||
<Card hover className="h-full p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground line-clamp-1">
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted">{s.participant}</p>
|
||||
{!s.isOwner && (
|
||||
<p className="text-xs text-muted mt-1">
|
||||
Par {s.user.name || s.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!s.isOwner && (
|
||||
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
|
||||
{s.role === 'EDITOR' ? '✏️' : '👁️'}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-2xl">🎯</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge variant="primary">
|
||||
{s._count.cards} motivations
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
Mis à jour le{' '}
|
||||
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user