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

@@ -14,10 +14,11 @@ import {
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson'];
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson'];
interface ShareUser {
id: string;
@@ -68,34 +69,38 @@ interface MotivatorSession {
workshopType: 'motivators';
}
type AnySession = SwotSession | MotivatorSession;
interface YearReviewSession {
id: string;
title: string;
participant: string;
resolvedParticipant: ResolvedCollaborator;
year: number;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { items: number };
workshopType: 'year-review';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
}
// Helper to get participant name from any session
function getParticipant(session: AnySession): string {
return session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
yearReviewSessions: YearReviewSession[];
}
// Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot'
? (session as SwotSession).resolvedCollaborator
: (session as MotivatorSession).resolvedParticipant;
}
// Get display name for grouping - prefer matched user name
function getDisplayName(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
if (resolved.matchedUser?.name) {
return resolved.matchedUser.name;
if (session.workshopType === 'swot') {
return (session as SwotSession).resolvedCollaborator;
} else if (session.workshopType === 'year-review') {
return (session as YearReviewSession).resolvedParticipant;
} else {
return (session as MotivatorSession).resolvedParticipant;
}
return resolved.raw;
}
// Get grouping key - use matched user ID if available, otherwise normalized raw string
@@ -132,7 +137,11 @@ function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
return grouped;
}
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -152,9 +161,11 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
};
// Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions: AnySession[] = [
...swotSessions,
...motivatorSessions,
...yearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs)
const filteredSessions =
@@ -162,7 +173,9 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
? allSessions
: activeTab === 'swot'
? swotSessions
: motivatorSessions;
: activeTab === 'motivators'
? motivatorSessions
: yearReviewSessions;
// Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -206,6 +219,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
label="Moving Motivators"
count={motivatorSessions.length}
/>
<TabButton
active={activeTab === 'year-review'}
onClick={() => setActiveTab('year-review')}
icon="📅"
label="Year Review"
count={yearReviewSessions.length}
/>
</div>
{/* Sessions */}
@@ -316,22 +336,33 @@ function SessionCard({ session }: { session: AnySession }) {
const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant
: session.workshopType === 'year-review'
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant
);
const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : '🎯';
const isYearReview = session.workshopType === 'year-review';
const href = isSwot
? `/sessions/${session.id}`
: isYearReview
? `/year-review/${session.id}`
: `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
const participant = isSwot
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
: isYearReview
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6';
const handleDelete = () => {
startTransition(async () => {
const result = isSwot
? await deleteSwotSession(session.id)
: await deleteMotivatorSession(session.id);
: isYearReview
? await deleteYearReviewSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) {
setShowDeleteModal(false);
@@ -345,10 +376,15 @@ function SessionCard({ session }: { session: AnySession }) {
startTransition(async () => {
const result = isSwot
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
: isYearReview
? await updateYearReviewSession(session.id, {
title: editTitle,
participant: editParticipant,
})
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) {
setShowEditModal(false);
@@ -414,6 +450,12 @@ function SessionCard({ session }: { session: AnySession }) {
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : isYearReview ? (
<>
<span>{(session as YearReviewSession)._count.items} items</span>
<span>·</span>
<span>Année {(session as YearReviewSession).year}</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
@@ -32,10 +33,11 @@ export default async function SessionsPage() {
return null;
}
// Fetch both SWOT and Moving Motivators sessions
const [swotSessions, motivatorSessions] = await Promise.all([
// Fetch SWOT, Moving Motivators, and Year Review sessions
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
]);
// Add type to each session for unified display
@@ -49,10 +51,17 @@ export default async function SessionsPage() {
workshopType: 'motivators' as const,
}));
const allYearReviewSessions = yearReviewSessions.map((s) => ({
...s,
workshopType: 'year-review' as const,
}));
// Combine and sort by updatedAt
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0;
@@ -72,11 +81,17 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<Button variant="outline">
<span>🎯</span>
Nouveau Motivators
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Nouveau Year Review
</Button>
</Link>
</div>
</div>
@@ -88,8 +103,8 @@ export default async function SessionsPage() {
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators
pour découvrir les motivations de vos collaborateurs.
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
découvrir les motivations, ou un Year Review pour faire le bilan de l&apos;année.
</p>
<div className="flex gap-3 justify-center">
<Link href="/sessions/new">
@@ -99,16 +114,26 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<Button variant="outline">
<span>🎯</span>
Créer un Moving Motivators
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Créer un Year Review
</Button>
</Link>
</div>
</Card>
) : (
<Suspense fallback={<WorkshopTabsSkeleton />}>
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
<WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
/>
</Suspense>
)}
</main>