feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience

This commit is contained in:
Julien Froidefond
2025-11-28 08:40:39 +01:00
parent a5c17e23f6
commit 448cf61e66
26 changed files with 3191 additions and 183 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react';
import { useTheme } from '@/contexts/ThemeContext';
import { useState } from 'react';
@@ -9,23 +10,89 @@ export function Header() {
const { theme, toggleTheme } = useTheme();
const { data: session, status } = useSession();
const [menuOpen, setMenuOpen] = useState(false);
const [workshopsOpen, setWorkshopsOpen] = useState(false);
const pathname = usePathname();
const isActiveLink = (path: string) => pathname.startsWith(path);
return (
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl">📊</span>
<span className="text-xl font-bold text-foreground">SWOT Manager</span>
<span className="text-2xl">🚀</span>
<span className="text-xl font-bold text-foreground">Workshop Manager</span>
</Link>
<nav className="flex items-center gap-4">
{status === 'authenticated' && session?.user && (
<Link
href="/sessions"
className="text-muted transition-colors hover:text-foreground"
>
Mes Sessions
</Link>
<>
{/* All Workshops Link */}
<Link
href="/sessions"
className={`text-sm font-medium transition-colors ${
isActiveLink('/sessions') && !isActiveLink('/sessions/')
? 'text-primary'
: 'text-muted hover:text-foreground'
}`}
>
Mes Ateliers
</Link>
{/* Workshops Dropdown */}
<div className="relative">
<button
onClick={() => setWorkshopsOpen(!workshopsOpen)}
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
isActiveLink('/sessions/') || isActiveLink('/motivators')
? 'text-primary'
: 'text-muted hover:text-foreground'
}`}
>
Ateliers
<svg
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{workshopsOpen && (
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
<Link
href="/sessions/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">📊</span>
<div>
<div className="font-medium">Analyse SWOT</div>
<div className="text-xs text-muted">Forces, faiblesses, opportunités</div>
</div>
</Link>
<Link
href="/motivators/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">🎯</span>
<div>
<div className="font-medium">Moving Motivators</div>
<div className="text-xs text-muted">Motivations intrinsèques</div>
</div>
</Link>
</div>
)}
</div>
</>
)}
<button

View File

@@ -0,0 +1,143 @@
'use client';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MOTIVATOR_BY_TYPE } from '@/lib/types';
interface InfluenceZoneProps {
cards: MotivatorCardType[];
onInfluenceChange: (cardId: string, influence: number) => void;
canEdit: boolean;
}
export function InfluenceZone({ cards, onInfluenceChange, canEdit }: InfluenceZoneProps) {
// Sort by importance (orderIndex)
const sortedCards = [...cards].sort((a, b) => b.orderIndex - a.orderIndex);
return (
<div className="space-y-4">
{/* Legend */}
<div className="flex justify-center gap-8 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-muted">Influence négative</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400" />
<span className="text-muted">Neutre</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-muted">Influence positive</span>
</div>
</div>
{/* Cards with sliders */}
<div className="space-y-3">
{sortedCards.map((card) => (
<InfluenceSlider
key={card.id}
card={card}
onInfluenceChange={onInfluenceChange}
disabled={!canEdit}
/>
))}
</div>
</div>
);
}
function InfluenceSlider({
card,
onInfluenceChange,
disabled,
}: {
card: MotivatorCardType;
onInfluenceChange: (cardId: string, influence: number) => void;
disabled: boolean;
}) {
const config = MOTIVATOR_BY_TYPE[card.type];
return (
<div
className={`
flex items-center gap-4 p-4 rounded-xl border border-border bg-card
transition-all hover:shadow-md
`}
>
{/* Card info */}
<div className="flex items-center gap-3 w-40 shrink-0">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
style={{ backgroundColor: `${config.color}20` }}
>
{config.icon}
</div>
<div>
<div className="font-medium text-foreground text-sm">{config.name}</div>
<div className="text-xs text-muted">#{card.orderIndex}</div>
</div>
</div>
{/* Influence slider */}
<div className="flex-1 flex items-center gap-4">
<span className="text-xs text-red-500 font-medium w-6 text-right">-3</span>
<div className="flex-1 relative">
{/* Track background */}
<div className="absolute inset-0 h-2 top-1/2 -translate-y-1/2 rounded-full bg-gradient-to-r from-red-500 via-gray-300 to-green-500" />
{/* Slider */}
<input
type="range"
min={-3}
max={3}
value={card.influence}
onChange={(e) => onInfluenceChange(card.id, parseInt(e.target.value))}
disabled={disabled}
className={`
relative w-full h-8 appearance-none bg-transparent cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-6
[&::-webkit-slider-thumb]:h-6
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:border-2
[&::-webkit-slider-thumb]:border-foreground
[&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-grab
[&::-webkit-slider-thumb]:active:cursor-grabbing
[&::-moz-range-thumb]:w-6
[&::-moz-range-thumb]:h-6
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border-2
[&::-moz-range-thumb]:border-foreground
[&::-moz-range-thumb]:shadow-md
[&::-moz-range-thumb]:cursor-grab
disabled:cursor-not-allowed
disabled:[&::-webkit-slider-thumb]:cursor-not-allowed
`}
/>
{/* Zero marker */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-4 bg-foreground/30 rounded-full pointer-events-none" />
</div>
<span className="text-xs text-green-500 font-medium w-6">+3</span>
</div>
{/* Current value */}
<div
className={`
w-12 h-8 rounded-lg flex items-center justify-center font-bold text-sm
${card.influence > 0 ? 'bg-green-500/20 text-green-600' : ''}
${card.influence < 0 ? 'bg-red-500/20 text-red-600' : ''}
${card.influence === 0 ? 'bg-muted/20 text-muted' : ''}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
</div>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import { useState, useTransition } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MotivatorCard } from './MotivatorCard';
import { MotivatorSummary } from './MotivatorSummary';
import { InfluenceZone } from './InfluenceZone';
import { reorderMotivatorCards, updateCardInfluence } from '@/actions/moving-motivators';
interface MotivatorBoardProps {
sessionId: string;
cards: MotivatorCardType[];
canEdit: boolean;
}
type Step = 'ranking' | 'influence' | 'summary';
export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: MotivatorBoardProps) {
const [cards, setCards] = useState(initialCards);
const [step, setStep] = useState<Step>('ranking');
const [isPending, startTransition] = useTransition();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Sort cards by orderIndex
const sortedCards = [...cards].sort((a, b) => a.orderIndex - b.orderIndex);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortedCards.findIndex((c) => c.id === active.id);
const newIndex = sortedCards.findIndex((c) => c.id === over.id);
const newCards = arrayMove(sortedCards, oldIndex, newIndex).map((card, index) => ({
...card,
orderIndex: index + 1,
}));
setCards(newCards);
// Persist to server
startTransition(async () => {
await reorderMotivatorCards(sessionId, newCards.map((c) => c.id));
});
}
function handleInfluenceChange(cardId: string, influence: number) {
setCards((prev) =>
prev.map((c) => (c.id === cardId ? { ...c, influence } : c))
);
startTransition(async () => {
await updateCardInfluence(cardId, sessionId, influence);
});
}
function nextStep() {
if (step === 'ranking') setStep('influence');
else if (step === 'influence') setStep('summary');
}
function prevStep() {
if (step === 'influence') setStep('ranking');
else if (step === 'summary') setStep('influence');
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70' : ''}`}>
{/* Progress Steps */}
<div className="flex items-center justify-center gap-4">
<StepIndicator
number={1}
label="Classement"
active={step === 'ranking'}
completed={step !== 'ranking'}
onClick={() => setStep('ranking')}
/>
<div className="h-px w-12 bg-border" />
<StepIndicator
number={2}
label="Influence"
active={step === 'influence'}
completed={step === 'summary'}
onClick={() => setStep('influence')}
/>
<div className="h-px w-12 bg-border" />
<StepIndicator
number={3}
label="Récapitulatif"
active={step === 'summary'}
completed={false}
onClick={() => setStep('summary')}
/>
</div>
{/* Step Content */}
{step === 'ranking' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Classez vos motivations par importance
</h2>
<p className="text-muted">
Glissez les cartes de gauche (moins important) à droite (plus important)
</p>
</div>
{/* Importance axis */}
<div className="relative">
<div className="flex justify-between text-sm text-muted mb-4 px-4">
<span> Moins important</span>
<span>Plus important </span>
</div>
{/* Cards container */}
<div className="bg-gradient-to-r from-red-500/10 via-yellow-500/10 to-green-500/10 rounded-2xl p-4 border border-border overflow-x-auto">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedCards.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-2 min-w-max px-2">
{sortedCards.map((card) => (
<MotivatorCard
key={card.id}
card={card}
disabled={!canEdit}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
{/* Next button */}
<div className="flex justify-end">
<button
onClick={nextStep}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Suivant
</button>
</div>
</div>
)}
{step === 'influence' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Évaluez l&apos;influence de chaque motivation
</h2>
<p className="text-muted">
Pour chaque carte, indiquez si cette motivation a une influence positive ou négative sur votre situation actuelle
</p>
</div>
<InfluenceZone
cards={sortedCards}
onInfluenceChange={handleInfluenceChange}
canEdit={canEdit}
/>
{/* Navigation buttons */}
<div className="flex justify-between">
<button
onClick={prevStep}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
>
Retour
</button>
<button
onClick={nextStep}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Voir le récapitulatif
</button>
</div>
</div>
)}
{step === 'summary' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Récapitulatif de vos Moving Motivators
</h2>
<p className="text-muted">
Voici l&apos;analyse de vos motivations et leur impact
</p>
</div>
<MotivatorSummary cards={sortedCards} />
{/* Navigation buttons */}
<div className="flex justify-start">
<button
onClick={prevStep}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
>
Modifier
</button>
</div>
</div>
)}
</div>
);
}
function StepIndicator({
number,
label,
active,
completed,
onClick,
}: {
number: number;
label: string;
active: boolean;
completed: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`
flex flex-col items-center gap-1 transition-colors
${active ? 'text-primary' : completed ? 'text-green-500' : 'text-muted'}
`}
>
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
${active ? 'bg-primary text-primary-foreground' : ''}
${completed ? 'bg-green-500 text-white' : ''}
${!active && !completed ? 'bg-muted/20 text-muted' : ''}
`}
>
{completed ? '✓' : number}
</div>
<span className="text-xs font-medium">{label}</span>
</button>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MOTIVATOR_BY_TYPE } from '@/lib/types';
interface MotivatorCardProps {
card: MotivatorCardType;
onInfluenceChange?: (influence: number) => void;
disabled?: boolean;
showInfluence?: boolean;
}
export function MotivatorCard({
card,
disabled = false,
showInfluence = false,
}: MotivatorCardProps) {
const config = MOTIVATOR_BY_TYPE[card.type];
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: card.id,
disabled,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`
relative flex flex-col items-center justify-center
w-28 h-36 rounded-xl border-2 shrink-0
bg-card shadow-md
cursor-grab active:cursor-grabbing
transition-all duration-200
${isDragging ? 'opacity-50 scale-105 shadow-xl z-50' : 'hover:shadow-lg hover:-translate-y-1'}
${disabled ? 'cursor-default opacity-60' : ''}
`}
{...attributes}
{...listeners}
>
{/* Color accent bar */}
<div
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
style={{ backgroundColor: config.color }}
/>
{/* Icon */}
<div className="text-3xl mb-1 mt-2">{config.icon}</div>
{/* Name */}
<div
className="font-semibold text-sm text-center px-2"
style={{ color: config.color }}
>
{config.name}
</div>
{/* Description */}
<p className="text-[10px] text-muted text-center px-2 mt-1 line-clamp-2">
{config.description}
</p>
{/* Influence indicator */}
{showInfluence && card.influence !== 0 && (
<div
className={`
absolute -top-2 -right-2 w-6 h-6 rounded-full
flex items-center justify-center text-xs font-bold text-white
${card.influence > 0 ? 'bg-green-500' : 'bg-red-500'}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
)}
{/* Rank badge */}
<div
className="absolute -bottom-2 left-1/2 -translate-x-1/2
bg-foreground text-background text-xs font-bold
w-5 h-5 rounded-full flex items-center justify-center"
>
{card.orderIndex}
</div>
</div>
);
}
// Non-draggable version for summary
export function MotivatorCardStatic({
card,
size = 'normal',
}: {
card: MotivatorCardType;
size?: 'small' | 'normal';
}) {
const config = MOTIVATOR_BY_TYPE[card.type];
const sizeClasses = {
small: 'w-20 h-24 text-2xl',
normal: 'w-28 h-36 text-3xl',
};
return (
<div
className={`
relative flex flex-col items-center justify-center
rounded-xl border-2 bg-card shadow-md
${sizeClasses[size]}
`}
>
{/* Color accent bar */}
<div
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
style={{ backgroundColor: config.color }}
/>
{/* Icon */}
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>
{config.icon}
</div>
{/* Name */}
<div
className={`font-semibold text-center px-2 ${size === 'small' ? 'text-xs' : 'text-sm'}`}
style={{ color: config.color }}
>
{config.name}
</div>
{/* Influence indicator */}
{card.influence !== 0 && (
<div
className={`
absolute -top-2 -right-2 rounded-full
flex items-center justify-center font-bold text-white
${card.influence > 0 ? 'bg-green-500' : 'bg-red-500'}
${size === 'small' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
)}
{/* Rank badge */}
<div
className={`
absolute -bottom-2 left-1/2 -translate-x-1/2
bg-foreground text-background font-bold
rounded-full flex items-center justify-center
${size === 'small' ? 'w-4 h-4 text-[10px]' : 'w-5 h-5 text-xs'}
`}
>
{card.orderIndex}
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState, useCallback } from 'react';
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { MotivatorShareModal } from './MotivatorShareModal';
import { Button } from '@/components/ui/Button';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface MotivatorLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
children: React.ReactNode;
}
export function MotivatorLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
children,
}: MotivatorLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: MotivatorLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useMotivatorLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-primary/10 text-xs font-medium text-primary"
title={share.user.name || share.user.email}
>
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShareModalOpen(true)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>
{children}
</div>
{/* Share Modal */}
<MotivatorShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface MotivatorShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function MotivatorShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: MotivatorShareModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareMotivatorSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeMotivatorShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Session Moving Motivators</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
Collaborateurs ({shares.length})
</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">
Aucun collaborateur pour le moment
</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && (
<p className="text-xs text-muted">{share.user.email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MotivatorCardStatic } from './MotivatorCard';
interface MotivatorSummaryProps {
cards: MotivatorCardType[];
}
export function MotivatorSummary({ cards }: MotivatorSummaryProps) {
// Sort by orderIndex (importance)
const sortedByImportance = [...cards].sort((a, b) => a.orderIndex - b.orderIndex);
// Top 3 most important (highest orderIndex)
const top3 = sortedByImportance.slice(-3).reverse();
// Bottom 3 least important (lowest orderIndex)
const bottom3 = sortedByImportance.slice(0, 3);
// Cards with positive influence
const positiveInfluence = cards.filter((c) => c.influence > 0).sort((a, b) => b.influence - a.influence);
// Cards with negative influence
const negativeInfluence = cards.filter((c) => c.influence < 0).sort((a, b) => a.influence - b.influence);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top 3 Most Important */}
<SummarySection
title="🏆 Top 3 - Plus importantes"
subtitle="Ces motivations vous animent le plus"
cards={top3}
emptyMessage="Classez vos cartes pour voir ce résultat"
variant="success"
/>
{/* Bottom 3 Least Important */}
<SummarySection
title="📉 Moins importantes"
subtitle="Ces motivations ont moins d'impact pour vous"
cards={bottom3}
emptyMessage="Classez vos cartes pour voir ce résultat"
variant="muted"
/>
{/* Positive Influence */}
<SummarySection
title="✨ Influence positive"
subtitle="Ces motivations sont satisfaites actuellement"
cards={positiveInfluence}
emptyMessage="Aucune motivation en influence positive"
variant="success"
/>
{/* Negative Influence */}
<SummarySection
title="⚠️ Influence négative"
subtitle="Ces motivations ne sont pas satisfaites"
cards={negativeInfluence}
emptyMessage="Aucune motivation en influence négative"
variant="danger"
/>
</div>
);
}
function SummarySection({
title,
subtitle,
cards,
emptyMessage,
variant,
}: {
title: string;
subtitle: string;
cards: MotivatorCardType[];
emptyMessage: string;
variant: 'success' | 'danger' | 'muted';
}) {
const borderColors = {
success: 'border-green-500/30 bg-green-500/5',
danger: 'border-red-500/30 bg-red-500/5',
muted: 'border-border bg-muted/5',
};
return (
<div className={`rounded-xl border-2 p-5 ${borderColors[variant]}`}>
<h3 className="font-semibold text-foreground mb-1">{title}</h3>
<p className="text-sm text-muted mb-4">{subtitle}</p>
{cards.length > 0 ? (
<div className="flex gap-3 flex-wrap justify-center">
{cards.map((card) => (
<MotivatorCardStatic key={card.id} card={card} size="small" />
))}
</div>
) : (
<p className="text-sm text-muted text-center py-4">{emptyMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { MotivatorBoard } from './MotivatorBoard';
export { MotivatorCard, MotivatorCardStatic } from './MotivatorCard';
export { MotivatorSummary } from './MotivatorSummary';
export { InfluenceZone } from './InfluenceZone';
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
export { MotivatorShareModal } from './MotivatorShareModal';