Compare commits

...

5 Commits

Author SHA1 Message Date
Julien Froidefond
163caa398c feat: implement Weather Workshop feature with models, UI components, and session management for enhanced team visibility and personal well-being tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m16s
2026-02-03 18:08:06 +01:00
Julien Froidefond
3a2eb83197 feat: add comparePeriods utility for sorting OKR periods and refactor ObjectivesPage to utilize it for improved period sorting
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8m28s
2026-01-28 13:58:01 +01:00
Julien Froidefond
e848e85b63 feat: implement period filtering in OKRsList component with toggle for viewing all OKRs or current quarter's OKRs
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-01-28 13:54:10 +01:00
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
42 changed files with 5131 additions and 57 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,15 @@ model User {
yearReviewSessions YearReviewSession[]
sharedYearReviewSessions YRSessionShare[]
yearReviewSessionEvents YRSessionEvent[]
// Weekly Check-in relations
weeklyCheckInSessions WeeklyCheckInSession[]
sharedWeeklyCheckInSessions WCISessionShare[]
weeklyCheckInSessionEvents WCISessionEvent[]
// Weather Workshop relations
weatherSessions WeatherSession[]
sharedWeatherSessions WeatherSessionShare[]
weatherSessionEvents WeatherSessionEvent[]
weatherEntries WeatherEntry[]
// Teams & OKRs relations
createdTeams Team[]
teamMembers TeamMember[]
@@ -365,3 +374,154 @@ model KeyResult {
@@index([okrId])
@@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])
}
// ============================================
// Weather Workshop
// ============================================
model WeatherSession {
id String @id @default(cuid())
title String
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entries WeatherEntry[]
shares WeatherSessionShare[]
events WeatherSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([date])
}
model WeatherEntry {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
performanceEmoji String? // Emoji météo pour Performance
moralEmoji String? // Emoji météo pour Moral
fluxEmoji String? // Emoji météo pour Flux
valueCreationEmoji String? // Emoji météo pour Création de valeur
notes String? // Notes globales
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sessionId, userId]) // Un seul entry par membre par session
@@index([sessionId])
@@index([userId])
}
model WeatherSessionShare {
id String @id @default(cuid())
sessionId String
session WeatherSession @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 WeatherSessionEvent {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ENTRY_CREATED, ENTRY_UPDATED, ENTRY_DELETED, SESSION_UPDATED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

225
src/actions/weather.ts Normal file
View File

@@ -0,0 +1,225 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as weatherService from '@/services/weather';
// ============================================
// Session Actions
// ============================================
export async function createWeatherSession(data: { title: string; date?: Date }) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const weatherSession = await weatherService.createWeatherSession(session.user.id, data);
revalidatePath('/weather');
revalidatePath('/sessions');
return { success: true, data: weatherSession };
} catch (error) {
console.error('Error creating weather session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateWeatherSession(
sessionId: string,
data: { title?: string; date?: Date }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weatherService.updateWeatherSession(sessionId, authSession.user.id, data);
// Emit event for real-time sync
await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
revalidatePath(`/weather/${sessionId}`);
revalidatePath('/weather');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating weather session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteWeatherSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weatherService.deleteWeatherSession(sessionId, authSession.user.id);
revalidatePath('/weather');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting weather session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Entry Actions
// ============================================
export async function createOrUpdateWeatherEntry(
sessionId: string,
data: {
performanceEmoji?: string | null;
moralEmoji?: string | null;
fluxEmoji?: string | null;
valueCreationEmoji?: string | null;
notes?: string | null;
}
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const entry = await weatherService.createOrUpdateWeatherEntry(sessionId, authSession.user.id, data);
// Emit event for real-time sync
const eventType = entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
eventType,
{
entryId: entry.id,
userId: entry.userId,
...data,
}
);
revalidatePath(`/weather/${sessionId}`);
return { success: true, data: entry };
} catch (error) {
console.error('Error creating/updating weather entry:', error);
return { success: false, error: 'Erreur lors de la sauvegarde' };
}
}
export async function deleteWeatherEntry(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weatherService.deleteWeatherEntry(sessionId, authSession.user.id);
// Emit event for real-time sync
await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
'ENTRY_DELETED',
{ userId: authSession.user.id }
);
revalidatePath(`/weather/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting weather entry:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareWeatherSession(
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 weatherService.shareWeatherSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/weather/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing weather session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function shareWeatherSessionToTeam(
sessionId: string,
teamId: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const shares = await weatherService.shareWeatherSessionToTeam(
sessionId,
authSession.user.id,
teamId,
role
);
revalidatePath(`/weather/${sessionId}`);
return { success: true, data: shares };
} catch (error) {
console.error('Error sharing weather session to team:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage à l\'équipe';
return { success: false, error: message };
}
}
export async function removeWeatherShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weatherService.removeWeatherShare(sessionId, authSession.user.id, shareUserId);
revalidatePath(`/weather/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing weather share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

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 {
canAccessWeatherSession,
getWeatherSessionEvents,
} from '@/services/weather';
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 canAccessWeatherSession(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 getWeatherSessionEvents(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 broadcastToWeatherSession(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

@@ -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

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { getUserOKRs } from '@/services/okrs';
import { Card } from '@/components/ui';
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
import { comparePeriods } from '@/lib/okr-utils';
export default async function ObjectivesPage() {
const session = await auth();
@@ -27,16 +28,7 @@ export default async function ObjectivesPage() {
{} as Record<string, typeof okrs>
);
const periods = Object.keys(okrsByPeriod).sort((a, b) => {
// Sort periods: extract year and quarter/period
const aMatch = a.match(/(\d{4})/);
const bMatch = b.match(/(\d{4})/);
if (aMatch && bMatch) {
const yearDiff = parseInt(bMatch[1]) - parseInt(aMatch[1]);
if (yearDiff !== 0) return yearDiff;
}
return b.localeCompare(a);
});
const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
return (
<main className="mx-auto max-w-7xl px-4 py-8">

View File

@@ -68,6 +68,38 @@ 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"
/>
{/* Weather Workshop Card */}
<WorkshopCard
href="/sessions?tab=weather"
icon="🌤️"
title="Météo"
tagline="Votre état en un coup d'œil"
description="Créez votre météo personnelle sur 4 axes clés (Performance, Moral, Flux, Création de valeur) et partagez-la avec votre équipe pour une meilleure visibilité de votre état."
features={[
'4 axes : Performance, Moral, Flux, Création de valeur',
'Emojis météo pour exprimer votre état visuellement',
'Notes globales pour détailler votre ressenti',
]}
accentColor="#3b82f6"
newHref="/weather/new"
/>
</div>
</section>
@@ -355,6 +387,182 @@ 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>
{/* Weather 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">Météo</h2>
<p className="text-blue-500 font-medium">Votre état en un coup d&apos;œil</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 créer une météo personnelle ?
</h3>
<p className="text-muted mb-4">
La météo est un outil simple et visuel pour exprimer rapidement votre état sur 4 axes clés.
En la partageant avec votre équipe, vous créez de la transparence et facilitez la communication
sur votre bien-être et votre performance.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Exprimer rapidement votre état avec des emojis météo intuitifs
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Partager votre météo avec votre équipe pour une meilleure visibilité
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Créer un espace de dialogue ouvert sur votre performance et votre moral
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Suivre l&apos;évolution de votre état dans le temps
</li>
</ul>
</div>
{/* The 4 axes */}
<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 axes de la météo
</h3>
<div className="space-y-3">
<CategoryPill icon="☀️" name="Performance" color="#f59e0b" description="Votre performance personnelle et l'atteinte de vos objectifs" />
<CategoryPill icon="😊" name="Moral" color="#22c55e" description="Votre moral actuel et votre ressenti" />
<CategoryPill icon="🌊" name="Flux" color="#3b82f6" description="Votre flux de travail personnel et les blocages éventuels" />
<CategoryPill icon="💎" name="Création de valeur" color="#8b5cf6" description="Votre création de valeur et votre apport" />
</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 votre météo"
description="Créez une nouvelle météo personnelle avec un titre et une date"
/>
<StepCard
number={2}
title="Choisir vos emojis"
description="Pour chaque axe, sélectionnez un emoji météo qui reflète votre état"
/>
<StepCard
number={3}
title="Ajouter des notes"
description="Complétez avec des notes globales pour détailler votre ressenti"
/>
<StepCard
number={4}
title="Partager avec l'équipe"
description="Partagez votre météo avec votre équipe ou une équipe entière pour qu'ils puissent voir votre état"
/>
</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,20 @@ 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';
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'weekly-checkin' | 'weather' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson'];
const VALID_TABS: WorkshopType[] = [
'all',
'swot',
'motivators',
'year-review',
'weekly-checkin',
'weather',
'byPerson',
];
interface ShareUser {
id: string;
@@ -84,12 +94,42 @@ 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';
}
interface WeatherSession {
id: string;
title: string;
date: Date;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { entries: number };
workshopType: 'weather';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
}
// Helper to get resolved collaborator from any session
@@ -98,6 +138,19 @@ 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 if (session.workshopType === 'weather') {
// For weather sessions, use the owner as the "participant" since it's a personal weather
const weatherSession = session as WeatherSession;
return {
raw: weatherSession.user.name || weatherSession.user.email,
matchedUser: {
id: weatherSession.user.id,
email: weatherSession.user.email,
name: weatherSession.user.name,
},
};
} else {
return (session as MotivatorSession).resolvedParticipant;
}
@@ -141,6 +194,8 @@ export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -165,6 +220,8 @@ export function WorkshopTabs({
...swotSessions,
...motivatorSessions,
...yearReviewSessions,
...weeklyCheckInSessions,
...weatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs)
@@ -175,7 +232,11 @@ export function WorkshopTabs({
? swotSessions
: activeTab === 'motivators'
? motivatorSessions
: yearReviewSessions;
: activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: weatherSessions;
// Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -226,6 +287,20 @@ 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}
/>
<TabButton
active={activeTab === 'weather'}
onClick={() => setActiveTab('weather')}
icon="🌤️"
label="Météo"
count={weatherSessions.length}
/>
</div>
{/* Sessions */}
@@ -338,23 +413,43 @@ function SessionCard({ session }: { session: AnySession }) {
? (session as SwotSession).collaborator
: session.workshopType === 'year-review'
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant
: session.workshopType === 'weather'
? ''
: (session as MotivatorSession).participant
);
const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const href = isSwot
? `/sessions/${session.id}`
: isYearReview
? `/year-review/${session.id}`
: `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
: isWeeklyCheckIn
? `/weekly-checkin/${session.id}`
: isWeather
? `/weather/${session.id}`
: `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : isWeather ? '🌤️' : '🎯';
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
: isWeather
? (session as WeatherSession).user.name || (session as WeatherSession).user.email
: (session as MotivatorSession).participant;
const accentColor = isSwot
? '#06b6d4'
: isYearReview
? '#f59e0b'
: isWeeklyCheckIn
? '#10b981'
: isWeather
? '#3b82f6'
: '#8b5cf6';
const handleDelete = () => {
startTransition(async () => {
@@ -362,7 +457,11 @@ function SessionCard({ session }: { session: AnySession }) {
? await deleteSwotSession(session.id)
: isYearReview
? await deleteYearReviewSession(session.id)
: await deleteMotivatorSession(session.id);
: isWeeklyCheckIn
? await deleteWeeklyCheckInSession(session.id)
: isWeather
? await deleteWeatherSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) {
setShowDeleteModal(false);
@@ -381,10 +480,17 @@ 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,
})
: isWeather
? await updateWeatherSession(session.id, { title: editTitle })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) {
setShowEditModal(false);
@@ -401,6 +507,8 @@ function SessionCard({ session }: { session: AnySession }) {
setShowEditModal(true);
};
const editParticipantLabel = isSwot ? 'Collaborateur' : isWeather ? '' : 'Participant';
return (
<>
<div className="relative group">
@@ -456,6 +564,28 @@ 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>
</>
) : isWeather ? (
<>
<span>{(session as WeatherSession)._count.entries} membres</span>
<span>·</span>
<span>
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
@@ -570,15 +700,17 @@ 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"
value={editParticipant}
onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
required
/>
{!isWeather && (
<Input
id="edit-participant"
value={editParticipant}
onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
required
/>
)}
</div>
<ModalFooter>
<Button
@@ -591,7 +723,7 @@ function SessionCard({ session }: { session: AnySession }) {
</Button>
<Button
type="submit"
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>

View File

@@ -4,6 +4,8 @@ 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 { getWeatherSessionsByUserId } from '@/services/weather';
import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
@@ -33,12 +35,15 @@ 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, Weekly Check-in, and Weather sessions
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] =
await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id),
]);
// Add type to each session for unified display
const allSwotSessions = swotSessions.map((s) => ({
@@ -56,11 +61,23 @@ export default async function SessionsPage() {
workshopType: 'year-review' as const,
}));
const allWeeklyCheckInSessions = weeklyCheckInSessions.map((s) => ({
...s,
workshopType: 'weekly-checkin' as const,
}));
const allWeatherSessions = weatherSessions.map((s) => ({
...s,
workshopType: 'weather' as const,
}));
// Combine and sort by updatedAt
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
...allWeeklyCheckInSessions,
...allWeatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0;
@@ -87,11 +104,23 @@ 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 variant="outline">
<span>📝</span>
Nouveau Check-in
</Button>
</Link>
<Link href="/weather/new">
<Button>
<span>🌤</span>
Nouvelle Météo
</Button>
</Link>
</div>
</div>
@@ -104,9 +133,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 +150,23 @@ 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 variant="outline">
<span>📝</span>
Créer un Check-in
</Button>
</Link>
<Link href="/weather/new">
<Button>
<span>🌤</span>
Créer une Météo
</Button>
</Link>
</div>
</Card>
) : (
@@ -133,6 +175,8 @@ export default async function SessionsPage() {
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions}
/>
</Suspense>
)}

View File

@@ -0,0 +1,102 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWeatherSessionById } from '@/services/weather';
import { getUserTeams } from '@/services/teams';
import { WeatherBoard, WeatherLiveWrapper, WeatherInfoPanel } from '@/components/weather';
import { Badge } from '@/components/ui';
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
interface WeatherSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function WeatherSessionPage({ params }: WeatherSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const [session, userTeams] = await Promise.all([
getWeatherSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
}
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=weather" className="hover:text-foreground">
Météo
</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>
<EditableWeatherTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.entries.length} membres</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Info sur les catégories */}
<WeatherInfoPanel />
{/* Live Wrapper + Board */}
<WeatherLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<WeatherBoard
sessionId={session.id}
currentUserId={authSession.user.id}
currentUser={{
id: authSession.user.id,
name: authSession.user.name ?? null,
email: authSession.user.email ?? '',
}}
entries={session.entries}
shares={session.shares}
owner={{
id: session.user.id,
name: session.user.name ?? null,
email: session.user.email ?? '',
}}
canEdit={session.canEdit}
/>
</WeatherLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createWeatherSession } from '@/actions/weather';
import { getWeekYearLabel } from '@/lib/date-utils';
export default function NewWeatherPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const dateStr = formData.get('date') as string;
const date = dateStr ? new Date(dateStr) : undefined;
if (!title) {
setError('Veuillez remplir le titre');
setLoading(false);
return;
}
const result = await createWeatherSession({ title, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/weather/${result.data?.id}`);
}
// Default date to today
const today = new Date().toISOString().split('T')[0];
// Update title when date changes
useEffect(() => {
setTitle(getWeekYearLabel(new Date(selectedDate)));
}, [selectedDate]);
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🌤</span>
Nouvelle Météo
</CardTitle>
<CardDescription>
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec votre équipe
</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 de la météo"
name="title"
placeholder="Ex: Météo S05 - 2026"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div>
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
Date de la météo
</label>
<input
id="date"
name="date"
type="date"
defaultValue={today}
onChange={(e) => setSelectedDate(e.target.value)}
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>Performance</strong> : Comment évaluez-vous votre performance personnelle ?
</li>
<li>
<strong>Moral</strong> : Quel est votre moral actuel ?
</li>
<li>
<strong>Flux</strong> : Comment se passe votre flux de travail personnel ?
</li>
<li>
<strong>Création de valeur</strong> : Comment évaluez-vous votre création de valeur ?
</li>
</ol>
<p className="text-sm text-muted mt-2">
💡 <strong>Astuce</strong> : Partagez votre météo avec votre équipe pour qu&apos;ils puissent voir votre état. Chaque membre peut créer sa propre météo et la partager !
</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 la météo
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

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,156 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { getWeekYearLabel } from '@/lib/date-utils';
export default function NewWeeklyCheckInPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
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];
// Update title when date changes
useEffect(() => {
setTitle(getWeekYearLabel(new Date(selectedDate)));
}, [selectedDate]);
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"
value={title}
onChange={(e) => setTitle(e.target.value)}
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}
onChange={(e) => setSelectedDate(e.target.value)}
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

@@ -1,9 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { OKRCard } from './OKRCard';
import { Card, ToggleGroup } from '@/components/ui';
import { Card, ToggleGroup, Button } from '@/components/ui';
import { getGravatarUrl } from '@/lib/gravatar';
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod } from '@/lib/okr-utils';
import type { OKR } from '@/lib/types';
type ViewMode = 'grid' | 'grouped';
@@ -33,6 +34,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
}
return 'detailed';
});
const [showAllPeriods, setShowAllPeriods] = useState(false);
useEffect(() => {
if (typeof window !== 'undefined') {
@@ -40,8 +42,21 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
}
}, [cardViewMode]);
const currentQuarterPeriod = getCurrentQuarterPeriod();
// Filter OKRs based on period filter
const filteredOKRsData = useMemo(() => {
if (showAllPeriods) {
return okrsData;
}
return okrsData.map((tm) => ({
...tm,
okrs: tm.okrs.filter((okr) => isCurrentQuarterPeriod(okr.period)),
}));
}, [okrsData, showAllPeriods]);
// Flatten OKRs for grid view
const allOKRs = okrsData.flatMap((tm) =>
const allOKRs = filteredOKRsData.flatMap((tm) =>
tm.okrs.map((okr) => ({
...okr,
teamMember: {
@@ -52,11 +67,37 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
if (allOKRs.length === 0) {
return (
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div>
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3>
<p className="text-muted">Aucun OKR n&apos;a encore é défini pour cette équipe</p>
</Card>
<div>
{/* View Toggle */}
<div className="mb-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
{!showAllPeriods && (
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm"
>
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
</Button>
</div>
</div>
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
{showAllPeriods ? 'Aucun OKR défini' : `Aucun OKR pour ${currentQuarterPeriod}`}
</h3>
<p className="text-muted">
{showAllPeriods
? "Aucun OKR n'a encore été défini pour cette équipe"
: `Aucun OKR n'a été défini pour le trimestre ${currentQuarterPeriod}. Cliquez sur "Afficher tous les OKR" pour voir les OKR d'autres périodes.`}
</p>
</Card>
</div>
);
}
@@ -64,8 +105,20 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
<div>
{/* View Toggle */}
<div className="mb-6 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
{!showAllPeriods && (
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm"
>
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
</Button>
<ToggleGroup
value={cardViewMode}
onChange={setCardViewMode}
@@ -140,7 +193,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
{/* Grouped View */}
{viewMode === 'grouped' ? (
<div className="space-y-8">
{okrsData
{filteredOKRsData
.filter((tm) => tm.okrs.length > 0)
.map((teamMember) => (
<div key={teamMember.user.id}>

View File

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

View File

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

View File

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

View File

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

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,8 @@ export { EditableTitle } from './EditableTitle';
export { EditableSessionTitle } from './EditableSessionTitle';
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
export { EditableWeatherTitle } from './EditableWeatherTitle';
export { Input } from './Input';
export { Modal, ModalFooter } from './Modal';
export { Select } from './Select';

View File

@@ -0,0 +1,146 @@
'use client';
import { useMemo } from 'react';
import { WeatherCard } from './WeatherCard';
interface WeatherEntry {
id: string;
userId: string;
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
notes: string | null;
user: {
id: string;
name: string | null;
email: string;
};
}
interface Share {
id: string;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface WeatherBoardProps {
sessionId: string;
currentUserId: string;
currentUser: {
id: string;
name: string | null;
email: string;
};
entries: WeatherEntry[];
shares: Share[];
owner: {
id: string;
name: string | null;
email: string;
};
canEdit: boolean;
}
export function WeatherBoard({
sessionId,
currentUserId,
entries,
shares,
owner,
canEdit,
}: WeatherBoardProps) {
// Get all users who have access: owner + shared users
const allUsers = useMemo(() => {
const usersMap = new Map<string, { id: string; name: string | null; email: string }>();
// Add owner
usersMap.set(owner.id, owner);
// Add shared users
shares.forEach((share) => {
usersMap.set(share.userId, share.user);
});
return Array.from(usersMap.values());
}, [owner, shares]);
// Create entries map for quick lookup
const entriesMap = useMemo(() => {
const map = new Map<string, WeatherEntry>();
entries.forEach((entry) => {
map.set(entry.userId, entry);
});
return map;
}, [entries]);
// Create entries for all users (with placeholder entries for users without entries)
const allEntries = useMemo(() => {
return allUsers.map((user) => {
const existingEntry = entriesMap.get(user.id);
if (existingEntry) {
return existingEntry;
}
// Create placeholder entry for user without entry
return {
id: '',
userId: user.id,
performanceEmoji: null,
moralEmoji: null,
fluxEmoji: null,
valueCreationEmoji: null,
notes: null,
user,
};
});
}, [allUsers, entriesMap]);
// Sort: current user first, then owner, then others
const sortedEntries = useMemo(() => {
return [...allEntries].sort((a, b) => {
if (a.userId === currentUserId) return -1;
if (b.userId === currentUserId) return 1;
if (a.userId === owner.id) return -1;
if (b.userId === owner.id) return 1;
return (a.user.name || a.user.email).localeCompare(b.user.name || b.user.email, 'fr');
});
}, [allEntries, currentUserId, owner.id]);
return (
<div className="overflow-x-auto rounded-lg border border-border bg-card">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-card-column">
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Membre</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Performance
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Moral</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Flux</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Création de valeur
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[300px]">
Notes
</th>
</tr>
</thead>
<tbody>
{sortedEntries.map((entry) => (
<WeatherCard
key={entry.userId}
sessionId={sessionId}
currentUserId={currentUserId}
entry={entry}
canEdit={canEdit}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState, useTransition } from 'react';
import { createOrUpdateWeatherEntry } from '@/actions/weather';
import { Avatar } from '@/components/ui/Avatar';
import { Textarea } from '@/components/ui/Textarea';
const WEATHER_EMOJIS = [
{ emoji: '', label: 'Aucun' },
{ emoji: '☀️', label: 'Soleil' },
{ emoji: '🌤️', label: 'Soleil derrière nuage' },
{ emoji: '⛅', label: 'Soleil et nuages' },
{ emoji: '☁️', label: 'Nuages' },
{ emoji: '🌦️', label: 'Soleil et pluie' },
{ emoji: '🌧️', label: 'Pluie' },
{ emoji: '⛈️', label: 'Orage et pluie' },
{ emoji: '🌩️', label: 'Éclair' },
{ emoji: '❄️', label: 'Neige' },
{ emoji: '🌨️', label: 'Neige qui tombe' },
{ emoji: '🌪️', label: 'Tornade' },
{ emoji: '🌫️', label: 'Brouillard' },
{ emoji: '🌈', label: 'Arc-en-ciel' },
{ emoji: '🌊', label: 'Vague' },
{ emoji: '🔥', label: 'Feu' },
{ emoji: '💨', label: 'Vent' },
{ emoji: '⭐', label: 'Étoile' },
{ emoji: '🌟', label: 'Étoile brillante' },
{ emoji: '✨', label: 'Étincelles' },
];
interface WeatherEntry {
id: string;
userId: string;
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
notes: string | null;
user: {
id: string;
name: string | null;
email: string;
};
}
interface WeatherCardProps {
sessionId: string;
currentUserId: string;
entry: WeatherEntry;
canEdit: boolean;
}
export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: WeatherCardProps) {
const [isPending, startTransition] = useTransition();
const [notes, setNotes] = useState(entry.notes || '');
const [performanceEmoji, setPerformanceEmoji] = useState(entry.performanceEmoji || null);
const [moralEmoji, setMoralEmoji] = useState(entry.moralEmoji || null);
const [fluxEmoji, setFluxEmoji] = useState(entry.fluxEmoji || null);
const [valueCreationEmoji, setValueCreationEmoji] = useState(entry.valueCreationEmoji || null);
const isCurrentUser = entry.userId === currentUserId;
const canEditThis = canEdit && isCurrentUser;
function handleEmojiChange(axis: 'performance' | 'moral' | 'flux' | 'valueCreation', emoji: string | null) {
if (!canEditThis) return;
// Calculate new values
const newPerformanceEmoji = axis === 'performance' ? emoji : performanceEmoji;
const newMoralEmoji = axis === 'moral' ? emoji : moralEmoji;
const newFluxEmoji = axis === 'flux' ? emoji : fluxEmoji;
const newValueCreationEmoji = axis === 'valueCreation' ? emoji : valueCreationEmoji;
// Update local state immediately
if (axis === 'performance') {
setPerformanceEmoji(emoji);
} else if (axis === 'moral') {
setMoralEmoji(emoji);
} else if (axis === 'flux') {
setFluxEmoji(emoji);
} else if (axis === 'valueCreation') {
setValueCreationEmoji(emoji);
}
// Save to server with new values
startTransition(async () => {
await createOrUpdateWeatherEntry(sessionId, {
performanceEmoji: newPerformanceEmoji,
moralEmoji: newMoralEmoji,
fluxEmoji: newFluxEmoji,
valueCreationEmoji: newValueCreationEmoji,
notes,
});
});
}
function handleNotesChange(newNotes: string) {
if (!canEditThis) return;
setNotes(newNotes);
}
function handleNotesBlur() {
if (!canEditThis) return;
startTransition(async () => {
await createOrUpdateWeatherEntry(sessionId, {
performanceEmoji,
moralEmoji,
fluxEmoji,
valueCreationEmoji,
notes,
});
});
}
// For current user without entry, we need to get user info from somewhere
// For now, we'll use a placeholder - in real app, you'd pass user info as prop
const user = entry.user;
return (
<tr className={`border-b border-border ${isPending ? 'opacity-50' : ''}`}>
{/* User column */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Avatar email={user.email} name={user.name} size={32} />
<span className="text-sm font-medium text-foreground">
{user.name || user.email || 'Vous'}
</span>
</div>
</td>
{/* Performance */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={performanceEmoji || ''}
onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{performanceEmoji || '-'}</div>
)}
</td>
{/* Moral */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={moralEmoji || ''}
onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{moralEmoji || '-'}</div>
)}
</td>
{/* Flux */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={fluxEmoji || ''}
onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{fluxEmoji || '-'}</div>
)}
</td>
{/* Création de valeur */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={valueCreationEmoji || ''}
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{valueCreationEmoji || '-'}</div>
)}
</td>
{/* Notes */}
<td className="px-4 py-3 min-w-[300px]">
{canEditThis ? (
<Textarea
value={notes}
onChange={(e) => handleNotesChange(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Notes globales..."
className="min-h-[120px] w-full resize-y"
rows={5}
/>
) : (
<div className="text-sm text-foreground whitespace-pre-wrap min-h-[120px]">
{notes || '-'}
</div>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
export function WeatherInfoPanel() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="mb-6 rounded-lg border border-border bg-card-hover">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card"
>
<h3 className="text-sm font-semibold text-foreground">Les 4 axes de la météo personnelle</h3>
<svg
className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="border-t border-border px-4 py-3">
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-xs font-medium text-foreground mb-0.5"> Performance</p>
<p className="text-xs text-muted leading-relaxed">
Votre performance personnelle et l&apos;atteinte de vos objectifs
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">😊 Moral</p>
<p className="text-xs text-muted leading-relaxed">
Votre moral actuel et votre ressenti
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">🌊 Flux</p>
<p className="text-xs text-muted leading-relaxed">
Votre flux de travail personnel et les blocages éventuels
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">💎 Création de valeur</p>
<p className="text-xs text-muted leading-relaxed">
Votre création de valeur et votre apport
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeatherShareModal } from './WeatherShareModal';
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 Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: Team[];
children: React.ReactNode;
}
export function WeatherLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: WeatherLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeatherLiveEvent) => {
// 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 } = useWeatherLive({
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 */}
<WeatherShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
/>
</>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
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 { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
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 Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
userTeams?: Team[];
}
export function WeatherShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
userTeams = [],
}: WeatherShareModalProps) {
const [shareType, setShareType] = useState<'user' | 'team'>('user');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = 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 () => {
let result;
if (shareType === 'team') {
result = await shareWeatherSessionToTeam(sessionId, teamId, role);
} else {
result = await shareWeatherSession(sessionId, email, role);
}
if (result.success) {
setEmail('');
setTeamId('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeatherShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la météo">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Météo personnelle</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
{/* Share type selector */}
<div className="flex gap-2 border-b border-border pb-3">
<button
type="button"
onClick={() => {
setShareType('user');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👤 Utilisateur
</button>
<button
type="button"
onClick={() => {
setShareType('team');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'team'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👥 Équipe
</button>
</div>
{/* User share */}
{shareType === 'user' && (
<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
/>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
)}
{/* Team share */}
{shareType === 'team' && (
<div className="space-y-2">
{userTeams.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<>
<div className="relative">
<select
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
required
>
<option value="">Sélectionner une équipe</option>
{userTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name} {team.userRole === 'ADMIN' && '(Admin)'}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</>
)}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
disabled={isPending || (shareType === 'user' && !email) || (shareType === 'team' && !teamId)}
className="w-full"
>
{isPending ? 'Partage...' : shareType === 'team' ? 'Partager à l&apos;équipe' : '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 sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
export { WeatherBoard } from './WeatherBoard';
export { WeatherCard } from './WeatherCard';
export { WeatherLiveWrapper } from './WeatherLiveWrapper';
export { WeatherShareModal } from './WeatherShareModal';
export { WeatherInfoPanel } from './WeatherInfoPanel';

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 UseWeatherLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: WeatherLiveEvent) => void;
}
interface UseWeatherLiveReturn {
isConnected: boolean;
lastEvent: WeatherLiveEvent | null;
error: string | null;
}
export type WeatherLiveEvent = LiveEvent;
export function useWeatherLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseWeatherLiveOptions): UseWeatherLiveReturn {
return useLive({
sessionId,
apiPath: 'weather',
currentUserId,
enabled,
onEvent,
});
}

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,
});
}

21
src/lib/date-utils.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Get ISO week number for a given date
* ISO 8601 week numbering: week starts on Monday, first week contains Jan 4
*/
export function getISOWeek(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/**
* Get week number and year for a given date
* Returns format: "S06-2026"
*/
export function getWeekYearLabel(date: Date = new Date()): string {
const week = getISOWeek(date);
const year = date.getFullYear();
return `S${week.toString().padStart(2, '0')}-${year}`;
}

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

@@ -0,0 +1,58 @@
/**
* 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);
}
/**
* Parse a period string to extract year and quarter
* Returns { year, quarter } or null if format is not recognized
* Supports formats like "Q1 2025", "Q2 2024", etc.
*/
function parsePeriod(period: string): { year: number; quarter: number } | null {
// Match format "Q{quarter} {year}"
const match = period.match(/^Q(\d)\s+(\d{4})$/);
if (match) {
return {
year: parseInt(match[2], 10),
quarter: parseInt(match[1], 10),
};
}
return null;
}
/**
* Compare two period strings for sorting (most recent first)
* Returns negative if a should come before b, positive if after, 0 if equal
*/
export function comparePeriods(a: string, b: string): number {
const aParsed = parsePeriod(a);
const bParsed = parsePeriod(b);
// If both can be parsed, compare by year then quarter
if (aParsed && bParsed) {
// Most recent year first
const yearDiff = bParsed.year - aParsed.year;
if (yearDiff !== 0) return yearDiff;
// Most recent quarter first (same year)
return bParsed.quarter - aParsed.quarter;
}
// Fallback: if one can be parsed, prioritize it
if (aParsed && !bParsed) return -1;
if (!aParsed && bParsed) return 1;
// Both unparseable: fallback to string comparison (descending)
return b.localeCompare(a);
}

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,

388
src/services/weather.ts Normal file
View File

@@ -0,0 +1,388 @@
import { prisma } from '@/services/database';
import type { ShareRole } from '@prisma/client';
// ============================================
// Weather Session CRUD
// ============================================
export async function getWeatherSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.weatherSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
entries: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.weatherSessionShare.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: {
entries: 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()
);
return allSessions;
}
export async function getWeatherSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.weatherSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: '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';
return { ...session, isOwner, role, canEdit };
}
// Check if user can access session (owner or shared)
export async function canAccessWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.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 canEditWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) {
return prisma.weatherSession.create({
data: {
...data,
date: data.date || new Date(),
userId,
},
include: {
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
}
export async function updateWeatherSession(
sessionId: string,
userId: string,
data: { title?: string; date?: Date }
) {
return prisma.weatherSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteWeatherSession(sessionId: string, userId: string) {
return prisma.weatherSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Weather Entry CRUD
// ============================================
export async function getWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.findUnique({
where: {
sessionId_userId: { sessionId, userId },
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function createOrUpdateWeatherEntry(
sessionId: string,
userId: string,
data: {
performanceEmoji?: string | null;
moralEmoji?: string | null;
fluxEmoji?: string | null;
valueCreationEmoji?: string | null;
notes?: string | null;
}
) {
return prisma.weatherEntry.upsert({
where: {
sessionId_userId: { sessionId, userId },
},
update: data,
create: {
sessionId,
userId,
...data,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function deleteWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.deleteMany({
where: { sessionId, userId },
});
}
// ============================================
// Session Sharing
// ============================================
export async function shareWeatherSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weatherSession.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.weatherSessionShare.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 shareWeatherSessionToTeam(
sessionId: string,
ownerId: string,
teamId: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Get team members
const teamMembers = await prisma.teamMember.findMany({
where: { teamId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
if (teamMembers.length === 0) {
throw new Error('Team has no members');
}
// Share with all team members (except owner)
const shares = await Promise.all(
teamMembers
.filter((tm) => tm.userId !== ownerId) // Don't share with yourself
.map((tm) =>
prisma.weatherSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: tm.userId },
},
update: { role },
create: {
sessionId,
userId: tm.userId,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
})
)
);
return shares;
}
export async function removeWeatherShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.weatherSessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getWeatherSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessWeatherSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.weatherSessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type WeatherSessionEventType =
| 'ENTRY_CREATED'
| 'ENTRY_UPDATED'
| 'ENTRY_DELETED'
| 'SESSION_UPDATED';
export async function createWeatherSessionEvent(
sessionId: string,
userId: string,
type: WeatherSessionEventType,
payload: Record<string, unknown>
) {
return prisma.weatherSessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getWeatherSessionEvents(sessionId: string, since?: Date) {
return prisma.weatherSessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestWeatherEventTimestamp(sessionId: string) {
const event = await prisma.weatherSessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}

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;
}