feat: implement Year Review feature with session management, item categorization, and real-time collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s

This commit is contained in:
Julien Froidefond
2025-12-16 08:55:13 +01:00
parent 48ff86fb5f
commit 56a9c2c3be
21 changed files with 2480 additions and 50 deletions

View File

@@ -0,0 +1,94 @@
'use client';
import { useTransition } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import type { YearReviewItem, YearReviewCategory } from '@prisma/client';
import { YearReviewSection } from './YearReviewSection';
import { YearReviewCard } from './YearReviewCard';
import { moveYearReviewItem, reorderYearReviewItems } from '@/actions/year-review';
import { YEAR_REVIEW_SECTIONS } from '@/lib/types';
interface YearReviewBoardProps {
sessionId: string;
items: YearReviewItem[];
}
export function YearReviewBoard({ sessionId, items }: YearReviewBoardProps) {
const [isPending, startTransition] = useTransition();
const itemsByCategory = YEAR_REVIEW_SECTIONS.reduce(
(acc, section) => {
acc[section.category] = items
.filter((item) => item.category === section.category)
.sort((a, b) => a.order - b.order);
return acc;
},
{} as Record<YearReviewCategory, YearReviewItem[]>
);
function handleDragEnd(result: DropResult) {
if (!result.destination) return;
const { source, destination, draggableId } = result;
const sourceCategory = source.droppableId as YearReviewCategory;
const destCategory = destination.droppableId as YearReviewCategory;
// If same position, do nothing
if (sourceCategory === destCategory && source.index === destination.index) {
return;
}
startTransition(async () => {
if (sourceCategory === destCategory) {
// Same category - just reorder
const categoryItems = itemsByCategory[sourceCategory];
const itemIds = categoryItems.map((item) => item.id);
const [removed] = itemIds.splice(source.index, 1);
itemIds.splice(destination.index, 0, removed);
await reorderYearReviewItems(sessionId, sourceCategory, itemIds);
} else {
// Different category - move item
await moveYearReviewItem(draggableId, sessionId, destCategory, destination.index);
}
});
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
{/* Year Review Sections */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{YEAR_REVIEW_SECTIONS.map((section) => (
<Droppable key={section.category} droppableId={section.category}>
{(provided, snapshot) => (
<YearReviewSection
category={section.category}
sessionId={sessionId}
isDraggingOver={snapshot.isDraggingOver}
ref={provided.innerRef}
{...provided.droppableProps}
>
{itemsByCategory[section.category].map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(dragProvided, dragSnapshot) => (
<YearReviewCard
item={item}
sessionId={sessionId}
isDragging={dragSnapshot.isDragging}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
/>
)}
</Draggable>
))}
{provided.placeholder}
</YearReviewSection>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { forwardRef, useState, useTransition } from 'react';
import type { YearReviewItem } from '@prisma/client';
import { updateYearReviewItem, deleteYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
interface YearReviewCardProps {
item: YearReviewItem;
sessionId: string;
isDragging: boolean;
}
export const YearReviewCard = forwardRef<HTMLDivElement, YearReviewCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [isPending, startTransition] = useTransition();
const config = YEAR_REVIEW_BY_CATEGORY[item.category];
async function handleSave() {
if (content.trim() === item.content) {
setIsEditing(false);
return;
}
if (!content.trim()) {
// If empty, delete
startTransition(async () => {
await deleteYearReviewItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateYearReviewItem(item.id, sessionId, { content: content.trim() });
setIsEditing(false);
});
}
async function handleDelete() {
startTransition(async () => {
await deleteYearReviewItem(item.id, sessionId);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setContent(item.content);
setIsEditing(false);
}
}
return (
<div
ref={ref}
className={`
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
${isPending ? 'opacity-50' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '3px',
}}
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
{/* Actions (visible on hover) */}
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" 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>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</>
)}
</div>
);
}
);
YearReviewCard.displayName = 'YearReviewCard';

View File

@@ -0,0 +1,133 @@
'use client';
import { useState, useCallback } from 'react';
import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { YearReviewShareModal } from './YearReviewShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
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 YearReviewLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
children: React.ReactNode;
}
export function YearReviewLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
children,
}: YearReviewLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: YearReviewLiveEvent) => {
// 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 } = useYearReviewLive({
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) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{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 */}
<YearReviewShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { forwardRef, useState, useTransition, ReactNode } from 'react';
import type { YearReviewCategory } from '@prisma/client';
import { createYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
interface YearReviewSectionProps {
category: YearReviewCategory;
sessionId: string;
isDraggingOver: boolean;
children: ReactNode;
}
export const YearReviewSection = forwardRef<HTMLDivElement, YearReviewSectionProps>(
({ category, sessionId, isDraggingOver, children, ...props }, ref) => {
const [isAdding, setIsAdding] = useState(false);
const [newContent, setNewContent] = useState('');
const [isPending, startTransition] = useTransition();
const config = YEAR_REVIEW_BY_CATEGORY[category];
async function handleAdd() {
if (!newContent.trim()) {
setIsAdding(false);
return;
}
startTransition(async () => {
await createYearReviewItem(sessionId, {
content: newContent.trim(),
category,
});
setNewContent('');
setIsAdding(false);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAdd();
} else if (e.key === 'Escape') {
setIsAdding(false);
setNewContent('');
}
}
return (
<div
ref={ref}
className={`
rounded-xl border-2 p-4 min-h-[200px] transition-colors
bg-card border-border
${isDraggingOver ? 'ring-2 ring-primary ring-offset-2' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '4px',
}}
{...props}
>
{/* Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{config.icon}</span>
<div>
<h3 className="font-semibold text-foreground">{config.title}</h3>
<p className="text-xs text-muted">{config.description}</p>
</div>
</div>
<button
onClick={() => setIsAdding(true)}
className="rounded-lg p-1.5 transition-colors hover:bg-card-hover text-muted hover:text-foreground"
aria-label={`Ajouter un item ${config.title}`}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
{/* Items */}
<div className="space-y-2">
{children}
{/* Add Form */}
{isAdding && (
<div className="rounded-lg border border-border bg-card p-2 shadow-sm">
<textarea
autoFocus
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleAdd}
placeholder={`Décrivez ${config.title.toLowerCase()}...`}
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
<div className="mt-1 flex justify-end gap-1">
<button
onClick={() => {
setIsAdding(false);
setNewContent('');
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
Annuler
</button>
<button
onClick={handleAdd}
disabled={isPending || !newContent.trim()}
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
>
{isPending ? '...' : 'Ajouter'}
</button>
</div>
</div>
)}
</div>
</div>
);
}
);
YearReviewSection.displayName = 'YearReviewSection';

View File

@@ -0,0 +1,173 @@
'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 { Avatar } from '@/components/ui/Avatar';
import { shareYearReviewSession, removeYearReviewShare } from '@/actions/year-review';
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 YearReviewShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function YearReviewShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: YearReviewShareModalProps) {
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 shareYearReviewSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeYearReviewShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager le bilan">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Bilan annuel</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">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<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 items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
export { YearReviewBoard } from './YearReviewBoard';
export { YearReviewCard } from './YearReviewCard';
export { YearReviewSection } from './YearReviewSection';
export { YearReviewLiveWrapper } from './YearReviewLiveWrapper';
export { YearReviewShareModal } from './YearReviewShareModal';