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

This commit is contained in:
Julien Froidefond
2026-01-14 10:23:58 +01:00
parent 67d685d346
commit 53ee344ae7
23 changed files with 2753 additions and 24 deletions

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"
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>
</section>
@@ -355,6 +371,94 @@ export default function Home() {
</div>
</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 */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">

View File

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

View File

@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
@@ -33,12 +34,14 @@ export default async function SessionsPage() {
return null;
}
// 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),
]);
// Fetch SWOT, Moving Motivators, Year Review, and Weekly Check-in sessions
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions] =
await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
]);
// Add type to each session for unified display
const allSwotSessions = swotSessions.map((s) => ({
@@ -56,11 +59,17 @@ export default async function SessionsPage() {
workshopType: 'year-review' as const,
}));
const allWeeklyCheckInSessions = weeklyCheckInSessions.map((s) => ({
...s,
workshopType: 'weekly-checkin' as const,
}));
// Combine and sort by updatedAt
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
...allWeeklyCheckInSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0;
@@ -87,11 +96,17 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<Button variant="outline">
<span>📅</span>
Nouveau Year Review
</Button>
</Link>
<Link href="/weekly-checkin/new">
<Button>
<span>📝</span>
Nouveau Check-in
</Button>
</Link>
</div>
</div>
@@ -104,9 +119,10 @@ export default async function SessionsPage() {
</h2>
<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
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>
<div className="flex gap-3 justify-center">
<div className="flex gap-3 justify-center flex-wrap">
<Link href="/sessions/new">
<Button variant="outline">
<span>📊</span>
@@ -120,11 +136,17 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<Button variant="outline">
<span>📅</span>
Créer un Year Review
</Button>
</Link>
<Link href="/weekly-checkin/new">
<Button>
<span>📝</span>
Créer un Check-in
</Button>
</Link>
</div>
</Card>
) : (
@@ -133,6 +155,7 @@ export default async function SessionsPage() {
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions}
/>
</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

@@ -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 { EditableMotivatorTitle } from './EditableMotivatorTitle';
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
export { Input } from './Input';
export { Modal, ModalFooter } from './Modal';
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();
// ============================================
// 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(
okrId: string,
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;
}