Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Froidefond
53ee344ae7 feat: add Weekly Check-in feature with models, UI components, and session management for enhanced team collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m24s
2026-01-14 10:23:58 +01:00
Julien Froidefond
67d685d346 refactor: update component exports in OKRs, SWOT, Teams, and UI modules for improved organization and clarity 2026-01-13 14:51:50 +01:00
26 changed files with 2756 additions and 28 deletions

View File

@@ -0,0 +1,103 @@
-- CreateEnum
CREATE TABLE "WeeklyCheckInCategory" (
"value" TEXT NOT NULL PRIMARY KEY
);
-- CreateEnum
CREATE TABLE "Emotion" (
"value" TEXT NOT NULL PRIMARY KEY
);
-- InsertEnumValues
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WELL');
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WRONG');
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('CURRENT_FOCUS');
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('NEXT_FOCUS');
-- InsertEnumValues
INSERT INTO "Emotion" ("value") VALUES ('PRIDE');
INSERT INTO "Emotion" ("value") VALUES ('JOY');
INSERT INTO "Emotion" ("value") VALUES ('SATISFACTION');
INSERT INTO "Emotion" ("value") VALUES ('GRATITUDE');
INSERT INTO "Emotion" ("value") VALUES ('CONFIDENCE');
INSERT INTO "Emotion" ("value") VALUES ('FRUSTRATION');
INSERT INTO "Emotion" ("value") VALUES ('WORRY');
INSERT INTO "Emotion" ("value") VALUES ('DISAPPOINTMENT');
INSERT INTO "Emotion" ("value") VALUES ('EXCITEMENT');
INSERT INTO "Emotion" ("value") VALUES ('ANTICIPATION');
INSERT INTO "Emotion" ("value") VALUES ('DETERMINATION');
INSERT INTO "Emotion" ("value") VALUES ('NONE');
-- CreateTable
CREATE TABLE "WeeklyCheckInSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"participant" TEXT NOT NULL,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeeklyCheckInSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeeklyCheckInItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"category" TEXT NOT NULL,
"emotion" TEXT NOT NULL DEFAULT 'NONE',
"order" INTEGER NOT NULL DEFAULT 0,
"sessionId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeeklyCheckInItem_category_fkey" FOREIGN KEY ("category") REFERENCES "WeeklyCheckInCategory" ("value") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WeeklyCheckInItem_emotion_fkey" FOREIGN KEY ("emotion") REFERENCES "Emotion" ("value") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WCISessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WCISessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WCISessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WCISessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WCISessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WCISessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "WeeklyCheckInSession_userId_idx" ON "WeeklyCheckInSession"("userId");
-- CreateIndex
CREATE INDEX "WeeklyCheckInSession_date_idx" ON "WeeklyCheckInSession"("date");
-- CreateIndex
CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId");
-- CreateIndex
CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category");
-- CreateIndex
CREATE INDEX "WCISessionShare_sessionId_idx" ON "WCISessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "WCISessionShare_userId_idx" ON "WCISessionShare"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "WCISessionShare_sessionId_userId_key" ON "WCISessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "WCISessionEvent_sessionId_createdAt_idx" ON "WCISessionEvent"("sessionId", "createdAt");

View File

@@ -25,6 +25,10 @@ model User {
yearReviewSessions YearReviewSession[] yearReviewSessions YearReviewSession[]
sharedYearReviewSessions YRSessionShare[] sharedYearReviewSessions YRSessionShare[]
yearReviewSessionEvents YRSessionEvent[] yearReviewSessionEvents YRSessionEvent[]
// Weekly Check-in relations
weeklyCheckInSessions WeeklyCheckInSession[]
sharedWeeklyCheckInSessions WCISessionShare[]
weeklyCheckInSessionEvents WCISessionEvent[]
// Teams & OKRs relations // Teams & OKRs relations
createdTeams Team[] createdTeams Team[]
teamMembers TeamMember[] teamMembers TeamMember[]
@@ -365,3 +369,88 @@ model KeyResult {
@@index([okrId]) @@index([okrId])
@@index([okrId, order]) @@index([okrId, order])
} }
// ============================================
// Weekly Check-in Workshop
// ============================================
enum WeeklyCheckInCategory {
WENT_WELL // Ce qui s'est bien passé
WENT_WRONG // Ce qui s'est mal passé
CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...)
NEXT_FOCUS // Les prochains enjeux
}
enum Emotion {
PRIDE // Fierté
JOY // Joie
SATISFACTION // Satisfaction
GRATITUDE // Gratitude
CONFIDENCE // Confiance
FRUSTRATION // Frustration
WORRY // Inquiétude
DISAPPOINTMENT // Déception
EXCITEMENT // Excitement
ANTICIPATION // Anticipation
DETERMINATION // Détermination
NONE // Aucune émotion
}
model WeeklyCheckInSession {
id String @id @default(cuid())
title String
participant String // Nom du participant
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items WeeklyCheckInItem[]
shares WCISessionShare[]
events WCISessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([date])
}
model WeeklyCheckInItem {
id String @id @default(cuid())
content String
category WeeklyCheckInCategory
emotion Emotion @default(NONE)
order Int @default(0)
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionId])
@@index([sessionId, category])
}
model WCISessionShare {
id String @id @default(cuid())
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model WCISessionEvent {
id String @id @default(cuid())
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

View File

@@ -0,0 +1,333 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as weeklyCheckInService from '@/services/weekly-checkin';
import type { WeeklyCheckInCategory, Emotion } from '@prisma/client';
// ============================================
// Session Actions
// ============================================
export async function createWeeklyCheckInSession(data: {
title: string;
participant: string;
date?: Date;
}) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const weeklyCheckInSession = await weeklyCheckInService.createWeeklyCheckInSession(
session.user.id,
data
);
revalidatePath('/weekly-checkin');
revalidatePath('/sessions');
return { success: true, data: weeklyCheckInSession };
} catch (error) {
console.error('Error creating weekly check-in session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateWeeklyCheckInSession(
sessionId: string,
data: { title?: string; participant?: string; date?: Date }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weeklyCheckInService.updateWeeklyCheckInSession(sessionId, authSession.user.id, data);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
revalidatePath(`/weekly-checkin/${sessionId}`);
revalidatePath('/weekly-checkin');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating weekly check-in session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteWeeklyCheckInSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weeklyCheckInService.deleteWeeklyCheckInSession(sessionId, authSession.user.id);
revalidatePath('/weekly-checkin');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting weekly check-in session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Item Actions
// ============================================
export async function createWeeklyCheckInItem(
sessionId: string,
data: { content: string; category: WeeklyCheckInCategory; emotion?: Emotion }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await weeklyCheckInService.createWeeklyCheckInItem(sessionId, data);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_CREATED',
{
itemId: item.id,
content: item.content,
category: item.category,
emotion: item.emotion,
}
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error creating weekly check-in item:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateWeeklyCheckInItem(
itemId: string,
sessionId: string,
data: { content?: string; category?: WeeklyCheckInCategory; emotion?: Emotion }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await weeklyCheckInService.updateWeeklyCheckInItem(itemId, data);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_UPDATED',
{
itemId: item.id,
...data,
}
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error updating weekly check-in item:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteWeeklyCheckInItem(itemId: string, sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weeklyCheckInService.deleteWeeklyCheckInItem(itemId);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_DELETED',
{ itemId }
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting weekly check-in item:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
export async function moveWeeklyCheckInItem(
itemId: string,
sessionId: string,
newCategory: WeeklyCheckInCategory,
newOrder: number
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weeklyCheckInService.moveWeeklyCheckInItem(itemId, newCategory, newOrder);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_MOVED',
{
itemId,
category: newCategory,
order: newOrder,
}
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error moving weekly check-in item:', error);
return { success: false, error: 'Erreur lors du déplacement' };
}
}
export async function reorderWeeklyCheckInItems(
sessionId: string,
category: WeeklyCheckInCategory,
itemIds: string[]
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weeklyCheckInService.reorderWeeklyCheckInItems(sessionId, category, itemIds);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEMS_REORDERED',
{ category, itemIds }
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error reordering weekly check-in items:', error);
return { success: false, error: 'Erreur lors du réordonnancement' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareWeeklyCheckInSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await weeklyCheckInService.shareWeeklyCheckInSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing weekly check-in session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function removeWeeklyCheckInShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weeklyCheckInService.removeWeeklyCheckInShare(
sessionId,
authSession.user.id,
shareUserId
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing weekly check-in share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

View File

@@ -0,0 +1,122 @@
import { auth } from '@/lib/auth';
import {
canAccessWeeklyCheckInSession,
getWeeklyCheckInSessionEvents,
} from '@/services/weekly-checkin';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// Check access
const hasAccess = await canAccessWeeklyCheckInSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let lastEventTime = new Date();
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
},
cancel() {
// Remove connection on close
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getWeeklyCheckInSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 1000); // Poll every second
// Cleanup on abort
request.signal.addEventListener('abort', () => {
clearInterval(pollInterval);
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
// Helper to broadcast to all connections (called from actions)
export function broadcastToWeeklyCheckInSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
return;
}
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection might be closed, remove it
sessionConnections.delete(controller);
}
}
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
}

View File

@@ -68,6 +68,22 @@ export default function Home() {
accentColor="#f59e0b" accentColor="#f59e0b"
newHref="/year-review/new" newHref="/year-review/new"
/> />
{/* Weekly Check-in Workshop Card */}
<WorkshopCard
href="/sessions?tab=weekly-checkin"
icon="📝"
title="Weekly Check-in"
tagline="Le point hebdomadaire avec vos collaborateurs"
description="Chaque semaine, faites le point avec vos collaborateurs sur ce qui s'est bien passé, ce qui s'est mal passé, les enjeux du moment et les prochains enjeux."
features={[
'4 catégories : Bien passé, Mal passé, Enjeux du moment, Prochains enjeux',
'Ajout d\'émotions à chaque item (fierté, joie, frustration, etc.)',
'Suivi hebdomadaire régulier',
]}
accentColor="#10b981"
newHref="/weekly-checkin/new"
/>
</div> </div>
</section> </section>
@@ -355,6 +371,94 @@ export default function Home() {
</div> </div>
</section> </section>
{/* Weekly Check-in Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">📝</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2>
<p className="text-green-500 font-medium">Le point hebdomadaire avec vos collaborateurs</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi faire un check-in hebdomadaire ?
</h3>
<p className="text-muted mb-4">
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier
avec vos collaborateurs. Il favorise la communication, l&apos;alignement et la détection
précoce des problèmes ou opportunités.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Maintenir un suivi régulier et structuré avec chaque collaborateur
</li>
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Identifier rapidement les points positifs et les difficultés rencontrées
</li>
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Comprendre les priorités et enjeux du moment pour mieux accompagner
</li>
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Créer un espace d&apos;échange ouvert les émotions peuvent être exprimées
</li>
</ul>
</div>
{/* The 4 categories */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 4 catégories du check-in
</h3>
<div className="space-y-3">
<CategoryPill icon="✅" name="Ce qui s'est bien passé" color="#22c55e" description="Les réussites et points positifs" />
<CategoryPill icon="⚠️" name="Ce qui s'est mal passé" color="#ef4444" description="Les difficultés et points d'amélioration" />
<CategoryPill icon="🎯" name="Enjeux du moment" color="#3b82f6" description="Sur quoi je me concentre actuellement" />
<CategoryPill icon="🚀" name="Prochains enjeux" color="#8b5cf6" description="Ce sur quoi je vais me concentrer prochainement" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer le check-in"
description="Créez un nouveau check-in pour la semaine avec votre collaborateur"
/>
<StepCard
number={2}
title="Remplir les catégories"
description="Pour chaque catégorie, ajoutez les éléments pertinents de la semaine"
/>
<StepCard
number={3}
title="Ajouter des émotions"
description="Associez une émotion à chaque item pour mieux exprimer votre ressenti"
/>
<StepCard
number={4}
title="Partager et discuter"
description="Partagez le check-in avec votre collaborateur pour un échange constructif"
/>
</div>
</div>
</div>
</section>
{/* OKRs Deep Dive Section */} {/* OKRs Deep Dive Section */}
<section className="mb-16"> <section className="mb-16">
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-8">

View File

@@ -15,10 +15,18 @@ import {
import { deleteSwotSession, updateSwotSession } from '@/actions/session'; import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators'; import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review'; import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson'; type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'weekly-checkin' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson']; const VALID_TABS: WorkshopType[] = [
'all',
'swot',
'motivators',
'year-review',
'weekly-checkin',
'byPerson',
];
interface ShareUser { interface ShareUser {
id: string; id: string;
@@ -84,12 +92,28 @@ interface YearReviewSession {
workshopType: 'year-review'; workshopType: 'year-review';
} }
type AnySession = SwotSession | MotivatorSession | YearReviewSession; interface WeeklyCheckInSession {
id: string;
title: string;
participant: string;
resolvedParticipant: ResolvedCollaborator;
date: Date;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { items: number };
workshopType: 'weekly-checkin';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession;
interface WorkshopTabsProps { interface WorkshopTabsProps {
swotSessions: SwotSession[]; swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[]; motivatorSessions: MotivatorSession[];
yearReviewSessions: YearReviewSession[]; yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
} }
// Helper to get resolved collaborator from any session // Helper to get resolved collaborator from any session
@@ -98,6 +122,8 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return (session as SwotSession).resolvedCollaborator; return (session as SwotSession).resolvedCollaborator;
} else if (session.workshopType === 'year-review') { } else if (session.workshopType === 'year-review') {
return (session as YearReviewSession).resolvedParticipant; return (session as YearReviewSession).resolvedParticipant;
} else if (session.workshopType === 'weekly-checkin') {
return (session as WeeklyCheckInSession).resolvedParticipant;
} else { } else {
return (session as MotivatorSession).resolvedParticipant; return (session as MotivatorSession).resolvedParticipant;
} }
@@ -141,6 +167,7 @@ export function WorkshopTabs({
swotSessions, swotSessions,
motivatorSessions, motivatorSessions,
yearReviewSessions, yearReviewSessions,
weeklyCheckInSessions,
}: WorkshopTabsProps) { }: WorkshopTabsProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -165,6 +192,7 @@ export function WorkshopTabs({
...swotSessions, ...swotSessions,
...motivatorSessions, ...motivatorSessions,
...yearReviewSessions, ...yearReviewSessions,
...weeklyCheckInSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs) // Filter based on active tab (for non-byPerson tabs)
@@ -175,7 +203,9 @@ export function WorkshopTabs({
? swotSessions ? swotSessions
: activeTab === 'motivators' : activeTab === 'motivators'
? motivatorSessions ? motivatorSessions
: yearReviewSessions; : activeTab === 'year-review'
? yearReviewSessions
: weeklyCheckInSessions;
// Separate by ownership // Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner); const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -226,6 +256,13 @@ export function WorkshopTabs({
label="Year Review" label="Year Review"
count={yearReviewSessions.length} count={yearReviewSessions.length}
/> />
<TabButton
active={activeTab === 'weekly-checkin'}
onClick={() => setActiveTab('weekly-checkin')}
icon="📝"
label="Weekly Check-in"
count={weeklyCheckInSessions.length}
/>
</div> </div>
{/* Sessions */} {/* Sessions */}
@@ -343,18 +380,29 @@ function SessionCard({ session }: { session: AnySession }) {
const isSwot = session.workshopType === 'swot'; const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review'; const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const href = isSwot const href = isSwot
? `/sessions/${session.id}` ? `/sessions/${session.id}`
: isYearReview : isYearReview
? `/year-review/${session.id}` ? `/year-review/${session.id}`
: isWeeklyCheckIn
? `/weekly-checkin/${session.id}`
: `/motivators/${session.id}`; : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯'; const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : '🎯';
const participant = isSwot const participant = isSwot
? (session as SwotSession).collaborator ? (session as SwotSession).collaborator
: isYearReview : isYearReview
? (session as YearReviewSession).participant ? (session as YearReviewSession).participant
: isWeeklyCheckIn
? (session as WeeklyCheckInSession).participant
: (session as MotivatorSession).participant; : (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6'; const accentColor = isSwot
? '#06b6d4'
: isYearReview
? '#f59e0b'
: isWeeklyCheckIn
? '#10b981'
: '#8b5cf6';
const handleDelete = () => { const handleDelete = () => {
startTransition(async () => { startTransition(async () => {
@@ -362,6 +410,8 @@ function SessionCard({ session }: { session: AnySession }) {
? await deleteSwotSession(session.id) ? await deleteSwotSession(session.id)
: isYearReview : isYearReview
? await deleteYearReviewSession(session.id) ? await deleteYearReviewSession(session.id)
: isWeeklyCheckIn
? await deleteWeeklyCheckInSession(session.id)
: await deleteMotivatorSession(session.id); : await deleteMotivatorSession(session.id);
if (result.success) { if (result.success) {
@@ -381,6 +431,11 @@ function SessionCard({ session }: { session: AnySession }) {
title: editTitle, title: editTitle,
participant: editParticipant, participant: editParticipant,
}) })
: isWeeklyCheckIn
? await updateWeeklyCheckInSession(session.id, {
title: editTitle,
participant: editParticipant,
})
: await updateMotivatorSession(session.id, { : await updateMotivatorSession(session.id, {
title: editTitle, title: editTitle,
participant: editParticipant, participant: editParticipant,
@@ -401,6 +456,8 @@ function SessionCard({ session }: { session: AnySession }) {
setShowEditModal(true); setShowEditModal(true);
}; };
const editParticipantLabel = isSwot ? 'Collaborateur' : 'Participant';
return ( return (
<> <>
<div className="relative group"> <div className="relative group">
@@ -456,6 +513,17 @@ function SessionCard({ session }: { session: AnySession }) {
<span>·</span> <span>·</span>
<span>Année {(session as YearReviewSession).year}</span> <span>Année {(session as YearReviewSession).year}</span>
</> </>
) : isWeeklyCheckIn ? (
<>
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
<span>·</span>
<span>
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : ( ) : (
<span>{(session as MotivatorSession)._count.cards}/10</span> <span>{(session as MotivatorSession)._count.cards}/10</span>
)} )}
@@ -570,7 +638,7 @@ function SessionCard({ session }: { session: AnySession }) {
htmlFor="edit-participant" htmlFor="edit-participant"
className="block text-sm font-medium text-foreground mb-1" className="block text-sm font-medium text-foreground mb-1"
> >
{isSwot ? 'Collaborateur' : 'Participant'} {editParticipantLabel}
</label> </label>
<Input <Input
id="edit-participant" id="edit-participant"

View File

@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions'; import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review'; import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
import { Card, Button } from '@/components/ui'; import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs'; import { WorkshopTabs } from './WorkshopTabs';
@@ -33,11 +34,13 @@ export default async function SessionsPage() {
return null; return null;
} }
// Fetch SWOT, Moving Motivators, and Year Review sessions // Fetch SWOT, Moving Motivators, Year Review, and Weekly Check-in sessions
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([ const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions] =
await Promise.all([
getSessionsByUserId(session.user.id), getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id), getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id), getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
]); ]);
// Add type to each session for unified display // Add type to each session for unified display
@@ -56,11 +59,17 @@ export default async function SessionsPage() {
workshopType: 'year-review' as const, workshopType: 'year-review' as const,
})); }));
const allWeeklyCheckInSessions = weeklyCheckInSessions.map((s) => ({
...s,
workshopType: 'weekly-checkin' as const,
}));
// Combine and sort by updatedAt // Combine and sort by updatedAt
const allSessions = [ const allSessions = [
...allSwotSessions, ...allSwotSessions,
...allMotivatorSessions, ...allMotivatorSessions,
...allYearReviewSessions, ...allYearReviewSessions,
...allWeeklyCheckInSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0; const hasNoSessions = allSessions.length === 0;
@@ -87,11 +96,17 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/year-review/new"> <Link href="/year-review/new">
<Button> <Button variant="outline">
<span>📅</span> <span>📅</span>
Nouveau Year Review Nouveau Year Review
</Button> </Button>
</Link> </Link>
<Link href="/weekly-checkin/new">
<Button>
<span>📝</span>
Nouveau Check-in
</Button>
</Link>
</div> </div>
</div> </div>
@@ -104,9 +119,10 @@ export default async function SessionsPage() {
</h2> </h2>
<p className="text-muted mb-6 max-w-md mx-auto"> <p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour 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. découvrir les motivations, un Year Review pour faire le bilan de l&apos;année, ou un
Weekly Check-in pour le suivi hebdomadaire.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center flex-wrap">
<Link href="/sessions/new"> <Link href="/sessions/new">
<Button variant="outline"> <Button variant="outline">
<span>📊</span> <span>📊</span>
@@ -120,11 +136,17 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/year-review/new"> <Link href="/year-review/new">
<Button> <Button variant="outline">
<span>📅</span> <span>📅</span>
Créer un Year Review Créer un Year Review
</Button> </Button>
</Link> </Link>
<Link href="/weekly-checkin/new">
<Button>
<span>📝</span>
Créer un Check-in
</Button>
</Link>
</div> </div>
</Card> </Card>
) : ( ) : (
@@ -133,6 +155,7 @@ export default async function SessionsPage() {
swotSessions={allSwotSessions} swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions} motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions} yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions}
/> />
</Suspense> </Suspense>
)} )}

View File

@@ -0,0 +1,101 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin';
import { getUserOKRsForPeriod } from '@/services/okrs';
import { getCurrentQuarterPeriod } from '@/lib/okr-utils';
import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin';
import { CurrentQuarterOKRs } from '@/components/weekly-checkin/CurrentQuarterOKRs';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableWeeklyCheckInTitle } from '@/components/ui';
interface WeeklyCheckInSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckInSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getWeeklyCheckInSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
// Get current quarter OKRs for the participant (NOT the creator)
// We use session.resolvedParticipant.matchedUser.id which is the participant's user ID
const currentQuarterPeriod = getCurrentQuarterPeriod(session.date);
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
// Only fetch OKRs if the participant is a recognized user (has matchedUser)
if (session.resolvedParticipant.matchedUser) {
// Use participant's ID, not session.userId (which is the creator's ID)
const participantUserId = session.resolvedParticipant.matchedUser.id;
currentQuarterOKRs = await getUserOKRsForPeriod(participantUserId, currentQuarterPeriod);
}
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="/sessions?tab=weekly-checkin" className="hover:text-foreground">
Weekly Check-in
</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>
<EditableWeeklyCheckInTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Current Quarter OKRs */}
{currentQuarterOKRs.length > 0 && (
<CurrentQuarterOKRs okrs={currentQuarterOKRs} period={currentQuarterPeriod} />
)}
{/* Live Wrapper + Board */}
<WeeklyCheckInLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<WeeklyCheckInBoard sessionId={session.id} items={session.items} />
</WeeklyCheckInLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
export default function NewWeeklyCheckInPage() {
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;
const dateStr = formData.get('date') as string;
const date = dateStr ? new Date(dateStr) : undefined;
if (!title || !participant) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createWeeklyCheckInSession({ title, participant, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/weekly-checkin/${result.data?.id}`);
}
// Default date to today
const today = new Date().toISOString().split('T')[0];
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📝</span>
Nouveau Check-in Hebdomadaire
</CardTitle>
<CardDescription>
Créez un check-in hebdomadaire pour faire le point sur la semaine avec votre
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 du check-in"
name="title"
placeholder="Ex: Check-in semaine du 15 janvier"
required
/>
<Input
label="Nom du collaborateur"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<div>
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
Date du check-in
</label>
<input
id="date"
name="date"
type="date"
defaultValue={today}
required
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<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>
<strong>Ce qui s&apos;est bien passé</strong> : Notez les réussites et points
positifs de la semaine
</li>
<li>
<strong>Ce qui s&apos;est mal passé</strong> : Identifiez les difficultés et
points d&apos;amélioration
</li>
<li>
<strong>Enjeux du moment</strong> : Décrivez sur quoi vous vous concentrez
actuellement
</li>
<li>
<strong>Prochains enjeux</strong> : Définissez ce sur quoi vous allez vous
concentrer prochainement
</li>
</ol>
<p className="text-sm text-muted mt-2">
💡 <strong>Astuce</strong> : Ajoutez une émotion à chaque item pour mieux exprimer
votre ressenti (fierté, joie, frustration, etc.)
</p>
</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 le check-in
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -3,4 +3,3 @@ export { OKRForm } from './OKRForm';
export { KeyResultItem } from './KeyResultItem'; export { KeyResultItem } from './KeyResultItem';
export { OKRsList } from './OKRsList'; export { OKRsList } from './OKRsList';
export { ObjectivesList } from './ObjectivesList'; export { ObjectivesList } from './ObjectivesList';

View File

@@ -2,4 +2,4 @@ export { SwotBoard } from './SwotBoard';
export { SwotQuadrant } from './SwotQuadrant'; export { SwotQuadrant } from './SwotQuadrant';
export { SwotCard } from './SwotCard'; export { SwotCard } from './SwotCard';
export { ActionPanel } from './ActionPanel'; export { ActionPanel } from './ActionPanel';
export { QuadrantHelp } from './QuadrantHelp'; export { QuadrantHelp, QuadrantHelpPanel } from './QuadrantHelp';

View File

@@ -1,5 +1,5 @@
export { TeamCard } from './TeamCard'; export { TeamCard } from './TeamCard';
export { TeamDetailClient } from './TeamDetailClient';
export { MembersList } from './MembersList'; export { MembersList } from './MembersList';
export { AddMemberModal } from './AddMemberModal'; export { AddMemberModal } from './AddMemberModal';
export { DeleteTeamButton } from './DeleteTeamButton'; export { DeleteTeamButton } from './DeleteTeamButton';

View File

@@ -0,0 +1,28 @@
'use client';
import { EditableTitle } from './EditableTitle';
import { updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
interface EditableWeeklyCheckInTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableWeeklyCheckInTitle({
sessionId,
initialTitle,
isOwner,
}: EditableWeeklyCheckInTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
onUpdate={async (id, title) => {
const result = await updateWeeklyCheckInSession(id, { title });
return result;
}}
/>
);
}

View File

@@ -7,6 +7,7 @@ export { EditableTitle } from './EditableTitle';
export { EditableSessionTitle } from './EditableSessionTitle'; export { EditableSessionTitle } from './EditableSessionTitle';
export { EditableMotivatorTitle } from './EditableMotivatorTitle'; export { EditableMotivatorTitle } from './EditableMotivatorTitle';
export { EditableYearReviewTitle } from './EditableYearReviewTitle'; export { EditableYearReviewTitle } from './EditableYearReviewTitle';
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
export { Input } from './Input'; export { Input } from './Input';
export { Modal, ModalFooter } from './Modal'; export { Modal, ModalFooter } from './Modal';
export { Select } from './Select'; export { Select } from './Select';

View File

@@ -0,0 +1,161 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Badge } from '@/components/ui';
import type { OKR } from '@/lib/types';
import { OKR_STATUS_LABELS } from '@/lib/types';
type OKRWithTeam = OKR & {
team?: {
id: string;
name: string;
} | null;
};
interface CurrentQuarterOKRsProps {
okrs: OKRWithTeam[];
period: string;
}
export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) {
const [isExpanded, setIsExpanded] = useState(true);
if (okrs.length === 0) {
return null;
}
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span>🎯</span>
<span>Objectifs du trimestre ({period})</span>
<svg
className={`w-5 h-5 transition-transform ${isExpanded ? '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>
</CardTitle>
</CardHeader>
{isExpanded && (
<CardContent>
<div className="space-y-3">
{okrs.map((okr) => {
const statusColors = getOKRStatusColor(okr.status);
return (
<div
key={okr.id}
className="rounded-lg border border-border bg-card p-3 hover:bg-card-hover transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-foreground">{okr.objective}</h4>
<Badge
variant="default"
style={{
backgroundColor: statusColors.bg,
color: statusColors.color,
borderColor: statusColors.color + '30',
}}
>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
{okr.progress !== undefined && (
<span className="text-xs text-muted">{okr.progress}%</span>
)}
</div>
{okr.description && (
<p className="text-sm text-muted mb-2">{okr.description}</p>
)}
{okr.keyResults && okr.keyResults.length > 0 && (
<ul className="space-y-1 mt-2">
{okr.keyResults.slice(0, 3).map((kr) => {
const krProgress = kr.targetValue > 0
? Math.round((kr.currentValue / kr.targetValue) * 100)
: 0;
return (
<li key={kr.id} className="text-xs text-muted flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="flex-1">{kr.title}</span>
<span className="text-muted">
{kr.currentValue}/{kr.targetValue} {kr.unit}
</span>
<span className="text-xs">({krProgress}%)</span>
</li>
);
})}
{okr.keyResults.length > 3 && (
<li className="text-xs text-muted pl-3.5">
+{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 > 1 ? 's' : ''}
</li>
)}
</ul>
)}
{okr.team && (
<div className="mt-2">
<span className="text-xs text-muted">Équipe: {okr.team.name}</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="mt-4 pt-4 border-t border-border">
<Link
href="/objectives"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
Voir tous les objectifs
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</CardContent>
)}
</Card>
);
}
function getOKRStatusColor(status: OKR['status']): { bg: string; color: string } {
switch (status) {
case 'NOT_STARTED':
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
color: '#6b7280',
};
case 'IN_PROGRESS':
return {
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)',
color: '#3b82f6',
};
case 'COMPLETED':
return {
bg: 'color-mix(in srgb, #10b981 15%, transparent)',
color: '#10b981',
};
case 'CANCELLED':
return {
bg: 'color-mix(in srgb, #ef4444 15%, transparent)',
color: '#ef4444',
};
default:
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
color: '#6b7280',
};
}
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useTransition } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import type { WeeklyCheckInItem, WeeklyCheckInCategory } from '@prisma/client';
import { WeeklyCheckInSection } from './WeeklyCheckInSection';
import { WeeklyCheckInCard } from './WeeklyCheckInCard';
import { moveWeeklyCheckInItem, reorderWeeklyCheckInItems } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_SECTIONS } from '@/lib/types';
interface WeeklyCheckInBoardProps {
sessionId: string;
items: WeeklyCheckInItem[];
}
export function WeeklyCheckInBoard({ sessionId, items }: WeeklyCheckInBoardProps) {
const [isPending, startTransition] = useTransition();
const itemsByCategory = WEEKLY_CHECK_IN_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<WeeklyCheckInCategory, WeeklyCheckInItem[]>
);
function handleDragEnd(result: DropResult) {
if (!result.destination) return;
const { source, destination, draggableId } = result;
const sourceCategory = source.droppableId as WeeklyCheckInCategory;
const destCategory = destination.droppableId as WeeklyCheckInCategory;
// 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 reorderWeeklyCheckInItems(sessionId, sourceCategory, itemIds);
} else {
// Different category - move item
await moveWeeklyCheckInItem(draggableId, sessionId, destCategory, destination.index);
}
});
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
{/* Weekly Check-in Sections */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{WEEKLY_CHECK_IN_SECTIONS.map((section) => (
<Droppable key={section.category} droppableId={section.category}>
{(provided, snapshot) => (
<WeeklyCheckInSection
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) => (
<WeeklyCheckInCard
item={item}
sessionId={sessionId}
isDragging={dragSnapshot.isDragging}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
/>
)}
</Draggable>
))}
{provided.placeholder}
</WeeklyCheckInSection>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { forwardRef, useState, useTransition } from 'react';
import type { WeeklyCheckInItem } from '@prisma/client';
import { updateWeeklyCheckInItem, deleteWeeklyCheckInItem } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
import { Select } from '@/components/ui/Select';
interface WeeklyCheckInCardProps {
item: WeeklyCheckInItem;
sessionId: string;
isDragging: boolean;
}
export const WeeklyCheckInCard = forwardRef<HTMLDivElement, WeeklyCheckInCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [emotion, setEmotion] = useState(item.emotion);
const [isPending, startTransition] = useTransition();
const config = WEEKLY_CHECK_IN_BY_CATEGORY[item.category];
const emotionConfig = EMOTION_BY_TYPE[item.emotion];
async function handleSave() {
if (content.trim() === item.content && emotion === item.emotion) {
setIsEditing(false);
return;
}
if (!content.trim()) {
// If empty, delete
startTransition(async () => {
await deleteWeeklyCheckInItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateWeeklyCheckInItem(item.id, sessionId, {
content: content.trim(),
emotion,
});
setIsEditing(false);
});
}
async function handleDelete() {
startTransition(async () => {
await deleteWeeklyCheckInItem(item.id, sessionId);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setContent(item.content);
setEmotion(item.emotion);
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 ? (
<div
className="space-y-2"
onBlur={(e) => {
// Don't close if focus moves to another element in this container
const currentTarget = e.currentTarget;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && currentTarget.contains(relatedTarget)) {
return;
}
// Only save on blur if content changed
if (content.trim() !== item.content || emotion !== item.emotion) {
handleSave();
} else {
setIsEditing(false);
}
}}
>
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
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}
/>
<Select
value={emotion}
onChange={(e) => setEmotion(e.target.value as typeof emotion)}
className="text-xs"
options={Object.values(EMOTION_BY_TYPE).map((em) => ({
value: em.emotion,
label: `${em.icon} ${em.label}`,
}))}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setContent(item.content);
setEmotion(item.emotion);
setIsEditing(false);
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
Annuler
</button>
<button
onClick={handleSave}
disabled={isPending || !content.trim()}
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
>
{isPending ? '...' : 'Enregistrer'}
</button>
</div>
</div>
) : (
<>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-foreground whitespace-pre-wrap flex-1">{item.content}</p>
{emotion !== 'NONE' && (
<div
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium shrink-0"
style={{
backgroundColor: `${emotionConfig.color}15`,
color: emotionConfig.color,
border: `1px solid ${emotionConfig.color}30`,
}}
title={emotionConfig.label}
>
<span>{emotionConfig.icon}</span>
<span>{emotionConfig.label}</span>
</div>
)}
</div>
{/* 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>
);
}
);
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';

View File

@@ -0,0 +1,132 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeeklyCheckInLive, type WeeklyCheckInLiveEvent } from '@/hooks/useWeeklyCheckInLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
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 WeeklyCheckInLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
children: React.ReactNode;
}
export function WeeklyCheckInLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
children,
}: WeeklyCheckInLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeeklyCheckInLiveEvent) => {
// 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 } = useWeeklyCheckInLive({
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 */}
<WeeklyCheckInShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
import type { WeeklyCheckInCategory } from '@prisma/client';
import { createWeeklyCheckInItem } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
import { Select } from '@/components/ui/Select';
interface WeeklyCheckInSectionProps {
category: WeeklyCheckInCategory;
sessionId: string;
isDraggingOver: boolean;
children: ReactNode;
}
export const WeeklyCheckInSection = forwardRef<HTMLDivElement, WeeklyCheckInSectionProps>(
({ category, sessionId, isDraggingOver, children, ...props }, ref) => {
const [isAdding, setIsAdding] = useState(false);
const [newContent, setNewContent] = useState('');
const [newEmotion, setNewEmotion] = useState<'NONE'>('NONE');
const [isPending, startTransition] = useTransition();
const isSubmittingRef = useRef(false);
const config = WEEKLY_CHECK_IN_BY_CATEGORY[category];
async function handleAdd() {
if (isSubmittingRef.current || !newContent.trim()) {
setIsAdding(false);
return;
}
isSubmittingRef.current = true;
startTransition(async () => {
await createWeeklyCheckInItem(sessionId, {
content: newContent.trim(),
category,
emotion: newEmotion,
});
setNewContent('');
setNewEmotion('NONE');
setIsAdding(false);
isSubmittingRef.current = false;
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAdd();
} else if (e.key === 'Escape') {
setIsAdding(false);
setNewContent('');
setNewEmotion('NONE');
}
}
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"
onBlur={(e) => {
// Don't close if focus moves to another element in this container
const currentTarget = e.currentTarget;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && currentTarget.contains(relatedTarget)) {
return;
}
// Only add on blur if content is not empty
if (newContent.trim()) {
handleAdd();
} else {
setIsAdding(false);
setNewContent('');
setNewEmotion('NONE');
}
}}
>
<textarea
autoFocus
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleKeyDown}
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-2 flex items-center justify-between gap-2">
<Select
value={newEmotion}
onChange={(e) => setNewEmotion(e.target.value as typeof newEmotion)}
className="text-xs flex-1"
options={Object.values(EMOTION_BY_TYPE).map((em) => ({
value: em.emotion,
label: `${em.icon} ${em.label}`,
}))}
/>
<div className="flex gap-1">
<button
onClick={() => {
setIsAdding(false);
setNewContent('');
setNewEmotion('NONE');
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
Annuler
</button>
<button
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur from textarea
}}
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>
</div>
);
}
);
WeeklyCheckInSection.displayName = 'WeeklyCheckInSection';

View File

@@ -0,0 +1,172 @@
'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 { shareWeeklyCheckInSession, removeWeeklyCheckInShare } from '@/actions/weekly-checkin';
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 WeeklyCheckInShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function WeeklyCheckInShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: WeeklyCheckInShareModalProps) {
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 shareWeeklyCheckInSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeeklyCheckInShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager le check-in">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Check-in hebdomadaire</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,6 @@
export { WeeklyCheckInBoard } from './WeeklyCheckInBoard';
export { WeeklyCheckInCard } from './WeeklyCheckInCard';
export { WeeklyCheckInSection } from './WeeklyCheckInSection';
export { WeeklyCheckInLiveWrapper } from './WeeklyCheckInLiveWrapper';
export { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
export { CurrentQuarterOKRs } from './CurrentQuarterOKRs';

View File

@@ -0,0 +1,31 @@
import { useLive, type LiveEvent } from './useLive';
interface UseWeeklyCheckInLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: WeeklyCheckInLiveEvent) => void;
}
interface UseWeeklyCheckInLiveReturn {
isConnected: boolean;
lastEvent: WeeklyCheckInLiveEvent | null;
error: string | null;
}
export type WeeklyCheckInLiveEvent = LiveEvent;
export function useWeeklyCheckInLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseWeeklyCheckInLiveOptions): UseWeeklyCheckInLiveReturn {
return useLive({
sessionId,
apiPath: 'weekly-checkin',
currentUserId,
enabled,
onEvent,
});
}

16
src/lib/okr-utils.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Get the current quarter period string (e.g., "Q1 2025") from a date
*/
export function getCurrentQuarterPeriod(date: Date = new Date()): string {
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-12
const quarter = Math.ceil(month / 3);
return `Q${quarter} ${year}`;
}
/**
* Check if a period string matches the current quarter
*/
export function isCurrentQuarterPeriod(period: string, date: Date = new Date()): boolean {
return period === getCurrentQuarterPeriod(date);
}

View File

@@ -571,3 +571,219 @@ function generatePeriodSuggestions(): string[] {
} }
export const PERIOD_SUGGESTIONS = generatePeriodSuggestions(); export const PERIOD_SUGGESTIONS = generatePeriodSuggestions();
// ============================================
// Weekly Check-in - Type Definitions
// ============================================
export type WeeklyCheckInCategory =
| 'WENT_WELL' // Ce qui s'est bien passé
| 'WENT_WRONG' // Ce qui s'est mal passé
| 'CURRENT_FOCUS' // Les enjeux du moment (je me concentre sur ...)
| 'NEXT_FOCUS'; // Les prochains enjeux
export type Emotion =
| 'PRIDE' // Fierté
| 'JOY' // Joie
| 'SATISFACTION' // Satisfaction
| 'GRATITUDE' // Gratitude
| 'CONFIDENCE' // Confiance
| 'FRUSTRATION' // Frustration
| 'WORRY' // Inquiétude
| 'DISAPPOINTMENT' // Déception
| 'EXCITEMENT' // Excitement
| 'ANTICIPATION' // Anticipation
| 'DETERMINATION' // Détermination
| 'NONE'; // Aucune émotion
export interface WeeklyCheckInItem {
id: string;
content: string;
category: WeeklyCheckInCategory;
emotion: Emotion;
order: number;
sessionId: string;
createdAt: Date;
updatedAt: Date;
}
export interface WeeklyCheckInSession {
id: string;
title: string;
participant: string;
date: Date;
userId: string;
items: WeeklyCheckInItem[];
createdAt: Date;
updatedAt: Date;
}
export interface CreateWeeklyCheckInSessionInput {
title: string;
participant: string;
date?: Date;
}
export interface UpdateWeeklyCheckInSessionInput {
title?: string;
participant?: string;
date?: Date;
}
export interface CreateWeeklyCheckInItemInput {
content: string;
category: WeeklyCheckInCategory;
emotion?: Emotion;
order?: number;
}
export interface UpdateWeeklyCheckInItemInput {
content?: string;
category?: WeeklyCheckInCategory;
emotion?: Emotion;
order?: number;
}
// ============================================
// Weekly Check-in - UI Config
// ============================================
export interface WeeklyCheckInSectionConfig {
category: WeeklyCheckInCategory;
title: string;
icon: string;
description: string;
color: string;
}
export const WEEKLY_CHECK_IN_SECTIONS: WeeklyCheckInSectionConfig[] = [
{
category: 'WENT_WELL',
title: 'Ce qui s\'est bien passé',
icon: '✅',
description: 'Les réussites et points positifs de la semaine',
color: '#22c55e', // green
},
{
category: 'WENT_WRONG',
title: 'Ce qui s\'est mal passé',
icon: '⚠️',
description: 'Les difficultés et points d\'amélioration',
color: '#ef4444', // red
},
{
category: 'CURRENT_FOCUS',
title: 'Enjeux du moment',
icon: '🎯',
description: 'Sur quoi je me concentre actuellement',
color: '#3b82f6', // blue
},
{
category: 'NEXT_FOCUS',
title: 'Prochains enjeux',
icon: '🚀',
description: 'Ce sur quoi je vais me concentrer prochainement',
color: '#8b5cf6', // purple
},
];
export const WEEKLY_CHECK_IN_BY_CATEGORY: Record<
WeeklyCheckInCategory,
WeeklyCheckInSectionConfig
> = WEEKLY_CHECK_IN_SECTIONS.reduce(
(acc, config) => {
acc[config.category] = config;
return acc;
},
{} as Record<WeeklyCheckInCategory, WeeklyCheckInSectionConfig>
);
export interface EmotionConfig {
emotion: Emotion;
label: string;
icon: string;
color: string;
}
export const EMOTIONS_CONFIG: EmotionConfig[] = [
{
emotion: 'PRIDE',
label: 'Fierté',
icon: '🦁',
color: '#f59e0b', // amber
},
{
emotion: 'JOY',
label: 'Joie',
icon: '😊',
color: '#eab308', // yellow
},
{
emotion: 'SATISFACTION',
label: 'Satisfaction',
icon: '😌',
color: '#22c55e', // green
},
{
emotion: 'GRATITUDE',
label: 'Gratitude',
icon: '🙏',
color: '#14b8a6', // teal
},
{
emotion: 'CONFIDENCE',
label: 'Confiance',
icon: '💪',
color: '#3b82f6', // blue
},
{
emotion: 'FRUSTRATION',
label: 'Frustration',
icon: '😤',
color: '#f97316', // orange
},
{
emotion: 'WORRY',
label: 'Inquiétude',
icon: '😟',
color: '#eab308', // yellow
},
{
emotion: 'DISAPPOINTMENT',
label: 'Déception',
icon: '😞',
color: '#ef4444', // red
},
{
emotion: 'EXCITEMENT',
label: 'Excitement',
icon: '🤩',
color: '#ec4899', // pink
},
{
emotion: 'ANTICIPATION',
label: 'Anticipation',
icon: '⏳',
color: '#8b5cf6', // purple
},
{
emotion: 'DETERMINATION',
label: 'Détermination',
icon: '🔥',
color: '#dc2626', // red
},
{
emotion: 'NONE',
label: 'Aucune',
icon: '—',
color: '#6b7280', // gray
},
];
export const EMOTION_BY_TYPE: Record<Emotion, EmotionConfig> = EMOTIONS_CONFIG.reduce(
(acc, config) => {
acc[config.emotion] = config;
return acc;
},
{} as Record<Emotion, EmotionConfig>
);

View File

@@ -183,6 +183,45 @@ export async function getUserOKRs(userId: string) {
); );
} }
export async function getUserOKRsForPeriod(userId: string, period: string) {
// Get all team members for this user
const teamMembers = await prisma.teamMember.findMany({
where: { userId },
include: {
team: {
select: {
id: true,
name: true,
},
},
okrs: {
where: {
period,
},
include: {
keyResults: {
orderBy: {
order: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
// Flatten and return OKRs with team info
return teamMembers.flatMap((tm) =>
tm.okrs.map((okr) => ({
...okr,
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
team: tm.team,
}))
);
}
export async function updateOKR( export async function updateOKR(
okrId: string, okrId: string,
data: UpdateOKRInput, data: UpdateOKRInput,

View File

@@ -0,0 +1,371 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { ShareRole, WeeklyCheckInCategory, Emotion } from '@prisma/client';
// ============================================
// Weekly Check-in Session CRUD
// ============================================
export async function getWeeklyCheckInSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.weeklyCheckInSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.wCISessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Resolve participants to users
const sessionsWithResolved = await Promise.all(
allSessions.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
return sessionsWithResolved;
}
export async function getWeeklyCheckInSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.weeklyCheckInSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
items: {
orderBy: [{ category: 'asc' }, { order: 'asc' }],
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
if (!session) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
// Resolve participant to user if it's an email
const resolvedParticipant = await resolveCollaborator(session.participant);
return { ...session, isOwner, role, canEdit, resolvedParticipant };
}
// Check if user can access session (owner or shared)
export async function canAccessWeeklyCheckInSession(sessionId: string, userId: string) {
const count = await prisma.weeklyCheckInSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
// Check if user can edit session (owner or EDITOR role)
export async function canEditWeeklyCheckInSession(sessionId: string, userId: string) {
const count = await prisma.weeklyCheckInSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
export async function createWeeklyCheckInSession(
userId: string,
data: { title: string; participant: string; date?: Date }
) {
return prisma.weeklyCheckInSession.create({
data: {
...data,
date: data.date || new Date(),
userId,
},
include: {
items: {
orderBy: [{ category: 'asc' }, { order: 'asc' }],
},
},
});
}
export async function updateWeeklyCheckInSession(
sessionId: string,
userId: string,
data: { title?: string; participant?: string; date?: Date }
) {
return prisma.weeklyCheckInSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteWeeklyCheckInSession(sessionId: string, userId: string) {
return prisma.weeklyCheckInSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Weekly Check-in Items CRUD
// ============================================
export async function createWeeklyCheckInItem(
sessionId: string,
data: { content: string; category: WeeklyCheckInCategory; emotion?: Emotion }
) {
// Get max order for this category in this session
const maxOrder = await prisma.weeklyCheckInItem.findFirst({
where: { sessionId, category: data.category },
orderBy: { order: 'desc' },
select: { order: true },
});
return prisma.weeklyCheckInItem.create({
data: {
...data,
emotion: data.emotion || 'NONE',
sessionId,
order: (maxOrder?.order ?? -1) + 1,
},
});
}
export async function updateWeeklyCheckInItem(
itemId: string,
data: { content?: string; category?: WeeklyCheckInCategory; emotion?: Emotion; order?: number }
) {
return prisma.weeklyCheckInItem.update({
where: { id: itemId },
data,
});
}
export async function deleteWeeklyCheckInItem(itemId: string) {
return prisma.weeklyCheckInItem.delete({
where: { id: itemId },
});
}
export async function moveWeeklyCheckInItem(
itemId: string,
newCategory: WeeklyCheckInCategory,
newOrder: number
) {
return prisma.weeklyCheckInItem.update({
where: { id: itemId },
data: {
category: newCategory,
order: newOrder,
},
});
}
export async function reorderWeeklyCheckInItems(
sessionId: string,
category: WeeklyCheckInCategory,
itemIds: string[]
) {
const updates = itemIds.map((id, index) =>
prisma.weeklyCheckInItem.update({
where: { id },
data: { order: index },
})
);
return prisma.$transaction(updates);
}
// ============================================
// Session Sharing
// ============================================
export async function shareWeeklyCheckInSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weeklyCheckInSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Find target user
const targetUser = await prisma.user.findUnique({
where: { email: targetEmail },
});
if (!targetUser) {
throw new Error('User not found');
}
// Can't share with yourself
if (targetUser.id === ownerId) {
throw new Error('Cannot share session with yourself');
}
// Create or update share
return prisma.wCISessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: targetUser.id },
},
update: { role },
create: {
sessionId,
userId: targetUser.id,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function removeWeeklyCheckInShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.weeklyCheckInSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.wCISessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getWeeklyCheckInSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessWeeklyCheckInSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.wCISessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type WCISessionEventType =
| 'ITEM_CREATED'
| 'ITEM_UPDATED'
| 'ITEM_DELETED'
| 'ITEM_MOVED'
| 'ITEMS_REORDERED'
| 'SESSION_UPDATED';
export async function createWeeklyCheckInSessionEvent(
sessionId: string,
userId: string,
type: WCISessionEventType,
payload: Record<string, unknown>
) {
return prisma.wCISessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getWeeklyCheckInSessionEvents(sessionId: string, since?: Date) {
return prisma.wCISessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestWeeklyCheckInEventTimestamp(sessionId: string) {
const event = await prisma.wCISessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}