Compare commits
8 Commits
47703db348
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
057732f00e | ||
|
|
e8ffccd286 | ||
|
|
ef0772f894 | ||
|
|
163caa398c | ||
|
|
3a2eb83197 | ||
|
|
e848e85b63 | ||
|
|
53ee344ae7 | ||
|
|
67d685d346 |
@@ -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");
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WeatherSession" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" 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 "WeatherSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WeatherEntry" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"performanceEmoji" TEXT,
|
||||||
|
"moralEmoji" TEXT,
|
||||||
|
"fluxEmoji" TEXT,
|
||||||
|
"valueCreationEmoji" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "WeatherEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "WeatherEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WeatherSessionShare" (
|
||||||
|
"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 "WeatherSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "WeatherSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WeatherSessionEvent" (
|
||||||
|
"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 "WeatherSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "WeatherSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WeatherSession_userId_idx" ON "WeatherSession"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WeatherSession_date_idx" ON "WeatherSession"("date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WeatherEntry_sessionId_userId_key" ON "WeatherEntry"("sessionId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WeatherEntry_sessionId_idx" ON "WeatherEntry"("sessionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WeatherEntry_userId_idx" ON "WeatherEntry"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WeatherSessionShare_sessionId_userId_key" ON "WeatherSessionShare"("sessionId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WeatherSessionShare_sessionId_idx" ON "WeatherSessionShare"("sessionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WeatherSessionShare_userId_idx" ON "WeatherSessionShare"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WeatherSessionEvent_sessionId_createdAt_idx" ON "WeatherSessionEvent"("sessionId", "createdAt");
|
||||||
@@ -25,6 +25,15 @@ model User {
|
|||||||
yearReviewSessions YearReviewSession[]
|
yearReviewSessions YearReviewSession[]
|
||||||
sharedYearReviewSessions YRSessionShare[]
|
sharedYearReviewSessions YRSessionShare[]
|
||||||
yearReviewSessionEvents YRSessionEvent[]
|
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
|
// Teams & OKRs relations
|
||||||
createdTeams Team[]
|
createdTeams Team[]
|
||||||
teamMembers TeamMember[]
|
teamMembers TeamMember[]
|
||||||
@@ -365,3 +374,154 @@ model KeyResult {
|
|||||||
@@index([okrId])
|
@@index([okrId])
|
||||||
@@index([okrId, order])
|
@@index([okrId, order])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Weekly Check-in Workshop
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum WeeklyCheckInCategory {
|
||||||
|
WENT_WELL // Ce qui s'est bien passé
|
||||||
|
WENT_WRONG // Ce qui s'est mal passé
|
||||||
|
CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...)
|
||||||
|
NEXT_FOCUS // Les prochains enjeux
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Emotion {
|
||||||
|
PRIDE // Fierté
|
||||||
|
JOY // Joie
|
||||||
|
SATISFACTION // Satisfaction
|
||||||
|
GRATITUDE // Gratitude
|
||||||
|
CONFIDENCE // Confiance
|
||||||
|
FRUSTRATION // Frustration
|
||||||
|
WORRY // Inquiétude
|
||||||
|
DISAPPOINTMENT // Déception
|
||||||
|
EXCITEMENT // Excitement
|
||||||
|
ANTICIPATION // Anticipation
|
||||||
|
DETERMINATION // Détermination
|
||||||
|
NONE // Aucune émotion
|
||||||
|
}
|
||||||
|
|
||||||
|
model WeeklyCheckInSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
participant String // Nom du participant
|
||||||
|
date DateTime @default(now())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
items WeeklyCheckInItem[]
|
||||||
|
shares WCISessionShare[]
|
||||||
|
events WCISessionEvent[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([date])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WeeklyCheckInItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
content String
|
||||||
|
category WeeklyCheckInCategory
|
||||||
|
emotion Emotion @default(NONE)
|
||||||
|
order Int @default(0)
|
||||||
|
sessionId String
|
||||||
|
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([sessionId, category])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WCISessionShare {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
role ShareRole @default(EDITOR)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([sessionId, userId])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WCISessionEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
|
||||||
|
payload String // JSON payload
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([sessionId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
|||||||
276
src/actions/weather.ts
Normal file
276
src/actions/weather.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import * as weatherService from '@/services/weather';
|
||||||
|
import { getUserById } from '@/services/auth';
|
||||||
|
import { broadcastToWeatherSession } from '@/app/api/weather/[id]/subscribe/route';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Get user info for broadcast
|
||||||
|
const user = await getUserById(authSession.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Utilisateur non trouvé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event for real-time sync
|
||||||
|
const event = await weatherService.createWeatherSessionEvent(
|
||||||
|
sessionId,
|
||||||
|
authSession.user.id,
|
||||||
|
'SESSION_UPDATED',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast immediately via SSE
|
||||||
|
broadcastToWeatherSession(sessionId, {
|
||||||
|
type: 'SESSION_UPDATED',
|
||||||
|
payload: data,
|
||||||
|
userId: authSession.user.id,
|
||||||
|
user: { id: user.id, name: user.name, email: user.email },
|
||||||
|
timestamp: event.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Get user info for broadcast
|
||||||
|
const user = await getUserById(authSession.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Utilisateur non trouvé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event for real-time sync
|
||||||
|
const eventType = entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
|
||||||
|
const event = await weatherService.createWeatherSessionEvent(
|
||||||
|
sessionId,
|
||||||
|
authSession.user.id,
|
||||||
|
eventType,
|
||||||
|
{
|
||||||
|
entryId: entry.id,
|
||||||
|
userId: entry.userId,
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast immediately via SSE
|
||||||
|
broadcastToWeatherSession(sessionId, {
|
||||||
|
type: eventType,
|
||||||
|
payload: {
|
||||||
|
entryId: entry.id,
|
||||||
|
userId: entry.userId,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
userId: authSession.user.id,
|
||||||
|
user: { id: user.id, name: user.name, email: user.email },
|
||||||
|
timestamp: event.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Get user info for broadcast
|
||||||
|
const user = await getUserById(authSession.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Utilisateur non trouvé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event for real-time sync
|
||||||
|
const event = await weatherService.createWeatherSessionEvent(
|
||||||
|
sessionId,
|
||||||
|
authSession.user.id,
|
||||||
|
'ENTRY_DELETED',
|
||||||
|
{ userId: authSession.user.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast immediately via SSE
|
||||||
|
broadcastToWeatherSession(sessionId, {
|
||||||
|
type: 'ENTRY_DELETED',
|
||||||
|
payload: { userId: authSession.user.id },
|
||||||
|
userId: authSession.user.id,
|
||||||
|
user: { id: user.id, name: user.name, email: user.email },
|
||||||
|
timestamp: event.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
333
src/actions/weekly-checkin.ts
Normal file
333
src/actions/weekly-checkin.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/api/weather/[id]/subscribe/route.ts
Normal file
122
src/app/api/weather/[id]/subscribe/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/api/weekly-checkin/[id]/subscribe/route.ts
Normal file
122
src/app/api/weekly-checkin/[id]/subscribe/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
|||||||
import { getUserOKRs } from '@/services/okrs';
|
import { getUserOKRs } from '@/services/okrs';
|
||||||
import { Card } from '@/components/ui';
|
import { Card } from '@/components/ui';
|
||||||
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
|
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
|
||||||
|
import { comparePeriods } from '@/lib/okr-utils';
|
||||||
|
|
||||||
export default async function ObjectivesPage() {
|
export default async function ObjectivesPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -27,16 +28,7 @@ export default async function ObjectivesPage() {
|
|||||||
{} as Record<string, typeof okrs>
|
{} as Record<string, typeof okrs>
|
||||||
);
|
);
|
||||||
|
|
||||||
const periods = Object.keys(okrsByPeriod).sort((a, b) => {
|
const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||||
|
|||||||
208
src/app/page.tsx
208
src/app/page.tsx
@@ -68,6 +68,38 @@ export default function Home() {
|
|||||||
accentColor="#f59e0b"
|
accentColor="#f59e0b"
|
||||||
newHref="/year-review/new"
|
newHref="/year-review/new"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Weekly Check-in Workshop Card */}
|
||||||
|
<WorkshopCard
|
||||||
|
href="/sessions?tab=weekly-checkin"
|
||||||
|
icon="📝"
|
||||||
|
title="Weekly Check-in"
|
||||||
|
tagline="Le point hebdomadaire avec vos collaborateurs"
|
||||||
|
description="Chaque semaine, faites le point avec vos collaborateurs sur ce qui s'est bien passé, ce qui s'est mal passé, les enjeux du moment et les prochains enjeux."
|
||||||
|
features={[
|
||||||
|
'4 catégories : Bien passé, Mal passé, Enjeux du moment, Prochains enjeux',
|
||||||
|
'Ajout d\'émotions à chaque item (fierté, joie, frustration, etc.)',
|
||||||
|
'Suivi hebdomadaire régulier',
|
||||||
|
]}
|
||||||
|
accentColor="#10b981"
|
||||||
|
newHref="/weekly-checkin/new"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -355,6 +387,182 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Weekly Check-in Deep Dive Section */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<span className="text-4xl">📝</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2>
|
||||||
|
<p className="text-green-500 font-medium">Le point hebdomadaire avec vos collaborateurs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 lg:grid-cols-2">
|
||||||
|
{/* Why */}
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||||
|
<span className="text-2xl">💡</span>
|
||||||
|
Pourquoi faire un check-in hebdomadaire ?
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted mb-4">
|
||||||
|
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier
|
||||||
|
avec vos collaborateurs. Il favorise la communication, l'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'échange ouvert où 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'œ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'é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 */}
|
{/* OKRs Deep Dive Section */}
|
||||||
<section className="mb-16">
|
<section className="mb-16">
|
||||||
<div className="flex items-center gap-3 mb-8">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
|||||||
@@ -15,10 +15,20 @@ import {
|
|||||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
||||||
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
|
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
|
||||||
|
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||||
|
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 {
|
interface ShareUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,12 +94,42 @@ interface YearReviewSession {
|
|||||||
workshopType: 'year-review';
|
workshopType: 'year-review';
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnySession = SwotSession | MotivatorSession | YearReviewSession;
|
interface WeeklyCheckInSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
participant: string;
|
||||||
|
resolvedParticipant: ResolvedCollaborator;
|
||||||
|
date: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
isOwner: boolean;
|
||||||
|
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||||
|
user: { id: string; name: string | null; email: string };
|
||||||
|
shares: Share[];
|
||||||
|
_count: { items: number };
|
||||||
|
workshopType: 'weekly-checkin';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
interface WorkshopTabsProps {
|
||||||
swotSessions: SwotSession[];
|
swotSessions: SwotSession[];
|
||||||
motivatorSessions: MotivatorSession[];
|
motivatorSessions: MotivatorSession[];
|
||||||
yearReviewSessions: YearReviewSession[];
|
yearReviewSessions: YearReviewSession[];
|
||||||
|
weeklyCheckInSessions: WeeklyCheckInSession[];
|
||||||
|
weatherSessions: WeatherSession[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get resolved collaborator from any session
|
// Helper to get resolved collaborator from any session
|
||||||
@@ -98,6 +138,19 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
|||||||
return (session as SwotSession).resolvedCollaborator;
|
return (session as SwotSession).resolvedCollaborator;
|
||||||
} else if (session.workshopType === 'year-review') {
|
} else if (session.workshopType === 'year-review') {
|
||||||
return (session as YearReviewSession).resolvedParticipant;
|
return (session as YearReviewSession).resolvedParticipant;
|
||||||
|
} else if (session.workshopType === 'weekly-checkin') {
|
||||||
|
return (session as WeeklyCheckInSession).resolvedParticipant;
|
||||||
|
} else 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 {
|
} else {
|
||||||
return (session as MotivatorSession).resolvedParticipant;
|
return (session as MotivatorSession).resolvedParticipant;
|
||||||
}
|
}
|
||||||
@@ -141,6 +194,8 @@ export function WorkshopTabs({
|
|||||||
swotSessions,
|
swotSessions,
|
||||||
motivatorSessions,
|
motivatorSessions,
|
||||||
yearReviewSessions,
|
yearReviewSessions,
|
||||||
|
weeklyCheckInSessions,
|
||||||
|
weatherSessions,
|
||||||
}: WorkshopTabsProps) {
|
}: WorkshopTabsProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -165,6 +220,8 @@ export function WorkshopTabs({
|
|||||||
...swotSessions,
|
...swotSessions,
|
||||||
...motivatorSessions,
|
...motivatorSessions,
|
||||||
...yearReviewSessions,
|
...yearReviewSessions,
|
||||||
|
...weeklyCheckInSessions,
|
||||||
|
...weatherSessions,
|
||||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
// Filter based on active tab (for non-byPerson tabs)
|
// Filter based on active tab (for non-byPerson tabs)
|
||||||
@@ -175,7 +232,11 @@ export function WorkshopTabs({
|
|||||||
? swotSessions
|
? swotSessions
|
||||||
: activeTab === 'motivators'
|
: activeTab === 'motivators'
|
||||||
? motivatorSessions
|
? motivatorSessions
|
||||||
: yearReviewSessions;
|
: activeTab === 'year-review'
|
||||||
|
? yearReviewSessions
|
||||||
|
: activeTab === 'weekly-checkin'
|
||||||
|
? weeklyCheckInSessions
|
||||||
|
: weatherSessions;
|
||||||
|
|
||||||
// Separate by ownership
|
// Separate by ownership
|
||||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||||
@@ -226,6 +287,20 @@ export function WorkshopTabs({
|
|||||||
label="Year Review"
|
label="Year Review"
|
||||||
count={yearReviewSessions.length}
|
count={yearReviewSessions.length}
|
||||||
/>
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'weekly-checkin'}
|
||||||
|
onClick={() => setActiveTab('weekly-checkin')}
|
||||||
|
icon="📝"
|
||||||
|
label="Weekly Check-in"
|
||||||
|
count={weeklyCheckInSessions.length}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'weather'}
|
||||||
|
onClick={() => setActiveTab('weather')}
|
||||||
|
icon="🌤️"
|
||||||
|
label="Météo"
|
||||||
|
count={weatherSessions.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sessions */}
|
{/* Sessions */}
|
||||||
@@ -338,23 +413,43 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
? (session as SwotSession).collaborator
|
? (session as SwotSession).collaborator
|
||||||
: session.workshopType === 'year-review'
|
: session.workshopType === 'year-review'
|
||||||
? (session as YearReviewSession).participant
|
? (session as YearReviewSession).participant
|
||||||
|
: session.workshopType === 'weather'
|
||||||
|
? ''
|
||||||
: (session as MotivatorSession).participant
|
: (session as MotivatorSession).participant
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSwot = session.workshopType === 'swot';
|
const isSwot = session.workshopType === 'swot';
|
||||||
const isYearReview = session.workshopType === 'year-review';
|
const isYearReview = session.workshopType === 'year-review';
|
||||||
|
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||||
|
const isWeather = session.workshopType === 'weather';
|
||||||
const href = isSwot
|
const href = isSwot
|
||||||
? `/sessions/${session.id}`
|
? `/sessions/${session.id}`
|
||||||
: isYearReview
|
: isYearReview
|
||||||
? `/year-review/${session.id}`
|
? `/year-review/${session.id}`
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? `/weekly-checkin/${session.id}`
|
||||||
|
: isWeather
|
||||||
|
? `/weather/${session.id}`
|
||||||
: `/motivators/${session.id}`;
|
: `/motivators/${session.id}`;
|
||||||
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
|
const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : isWeather ? '🌤️' : '🎯';
|
||||||
const participant = isSwot
|
const participant = isSwot
|
||||||
? (session as SwotSession).collaborator
|
? (session as SwotSession).collaborator
|
||||||
: isYearReview
|
: isYearReview
|
||||||
? (session as YearReviewSession).participant
|
? (session as YearReviewSession).participant
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? (session as WeeklyCheckInSession).participant
|
||||||
|
: isWeather
|
||||||
|
? (session as WeatherSession).user.name || (session as WeatherSession).user.email
|
||||||
: (session as MotivatorSession).participant;
|
: (session as MotivatorSession).participant;
|
||||||
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6';
|
const accentColor = isSwot
|
||||||
|
? '#06b6d4'
|
||||||
|
: isYearReview
|
||||||
|
? '#f59e0b'
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? '#10b981'
|
||||||
|
: isWeather
|
||||||
|
? '#3b82f6'
|
||||||
|
: '#8b5cf6';
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -362,6 +457,10 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
? await deleteSwotSession(session.id)
|
? await deleteSwotSession(session.id)
|
||||||
: isYearReview
|
: isYearReview
|
||||||
? await deleteYearReviewSession(session.id)
|
? await deleteYearReviewSession(session.id)
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? await deleteWeeklyCheckInSession(session.id)
|
||||||
|
: isWeather
|
||||||
|
? await deleteWeatherSession(session.id)
|
||||||
: await deleteMotivatorSession(session.id);
|
: await deleteMotivatorSession(session.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -381,6 +480,13 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
title: editTitle,
|
title: editTitle,
|
||||||
participant: editParticipant,
|
participant: editParticipant,
|
||||||
})
|
})
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? await updateWeeklyCheckInSession(session.id, {
|
||||||
|
title: editTitle,
|
||||||
|
participant: editParticipant,
|
||||||
|
})
|
||||||
|
: isWeather
|
||||||
|
? await updateWeatherSession(session.id, { title: editTitle })
|
||||||
: await updateMotivatorSession(session.id, {
|
: await updateMotivatorSession(session.id, {
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
participant: editParticipant,
|
participant: editParticipant,
|
||||||
@@ -401,6 +507,8 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editParticipantLabel = isSwot ? 'Collaborateur' : isWeather ? '' : 'Participant';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -456,6 +564,28 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Année {(session as YearReviewSession).year}</span>
|
<span>Année {(session as YearReviewSession).year}</span>
|
||||||
</>
|
</>
|
||||||
|
) : isWeeklyCheckIn ? (
|
||||||
|
<>
|
||||||
|
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>
|
||||||
|
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : 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>
|
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
||||||
)}
|
)}
|
||||||
@@ -570,8 +700,9 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
htmlFor="edit-participant"
|
htmlFor="edit-participant"
|
||||||
className="block text-sm font-medium text-foreground mb-1"
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
>
|
>
|
||||||
{isSwot ? 'Collaborateur' : 'Participant'}
|
{editParticipantLabel}
|
||||||
</label>
|
</label>
|
||||||
|
{!isWeather && (
|
||||||
<Input
|
<Input
|
||||||
id="edit-participant"
|
id="edit-participant"
|
||||||
value={editParticipant}
|
value={editParticipant}
|
||||||
@@ -579,6 +710,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
|
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -591,7 +723,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
|
disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
|
||||||
>
|
>
|
||||||
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { auth } from '@/lib/auth';
|
|||||||
import { getSessionsByUserId } from '@/services/sessions';
|
import { getSessionsByUserId } from '@/services/sessions';
|
||||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
||||||
import { getYearReviewSessionsByUserId } from '@/services/year-review';
|
import { getYearReviewSessionsByUserId } from '@/services/year-review';
|
||||||
|
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
|
||||||
|
import { getWeatherSessionsByUserId } from '@/services/weather';
|
||||||
import { Card, Button } from '@/components/ui';
|
import { Card, Button } from '@/components/ui';
|
||||||
import { WorkshopTabs } from './WorkshopTabs';
|
import { WorkshopTabs } from './WorkshopTabs';
|
||||||
|
|
||||||
@@ -33,11 +35,14 @@ export default async function SessionsPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch SWOT, Moving Motivators, and Year Review sessions
|
// Fetch SWOT, Moving Motivators, Year Review, Weekly Check-in, and Weather sessions
|
||||||
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([
|
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] =
|
||||||
|
await Promise.all([
|
||||||
getSessionsByUserId(session.user.id),
|
getSessionsByUserId(session.user.id),
|
||||||
getMotivatorSessionsByUserId(session.user.id),
|
getMotivatorSessionsByUserId(session.user.id),
|
||||||
getYearReviewSessionsByUserId(session.user.id),
|
getYearReviewSessionsByUserId(session.user.id),
|
||||||
|
getWeeklyCheckInSessionsByUserId(session.user.id),
|
||||||
|
getWeatherSessionsByUserId(session.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add type to each session for unified display
|
// Add type to each session for unified display
|
||||||
@@ -56,11 +61,23 @@ export default async function SessionsPage() {
|
|||||||
workshopType: 'year-review' as const,
|
workshopType: 'year-review' as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const allWeeklyCheckInSessions = weeklyCheckInSessions.map((s) => ({
|
||||||
|
...s,
|
||||||
|
workshopType: 'weekly-checkin' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allWeatherSessions = weatherSessions.map((s) => ({
|
||||||
|
...s,
|
||||||
|
workshopType: 'weather' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
// Combine and sort by updatedAt
|
// Combine and sort by updatedAt
|
||||||
const allSessions = [
|
const allSessions = [
|
||||||
...allSwotSessions,
|
...allSwotSessions,
|
||||||
...allMotivatorSessions,
|
...allMotivatorSessions,
|
||||||
...allYearReviewSessions,
|
...allYearReviewSessions,
|
||||||
|
...allWeeklyCheckInSessions,
|
||||||
|
...allWeatherSessions,
|
||||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
const hasNoSessions = allSessions.length === 0;
|
const hasNoSessions = allSessions.length === 0;
|
||||||
@@ -87,11 +104,23 @@ export default async function SessionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/year-review/new">
|
<Link href="/year-review/new">
|
||||||
<Button>
|
<Button variant="outline">
|
||||||
<span>📅</span>
|
<span>📅</span>
|
||||||
Nouveau Year Review
|
Nouveau Year Review
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/weekly-checkin/new">
|
||||||
|
<Button variant="outline">
|
||||||
|
<span>📝</span>
|
||||||
|
Nouveau Check-in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/weather/new">
|
||||||
|
<Button>
|
||||||
|
<span>🌤️</span>
|
||||||
|
Nouvelle Météo
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,9 +133,10 @@ export default async function SessionsPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted mb-6 max-w-md mx-auto">
|
<p className="text-muted mb-6 max-w-md mx-auto">
|
||||||
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
|
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
|
||||||
découvrir les motivations, ou un Year Review pour faire le bilan de l'année.
|
découvrir les motivations, un Year Review pour faire le bilan de l'année, ou un
|
||||||
|
Weekly Check-in pour le suivi hebdomadaire.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center flex-wrap">
|
||||||
<Link href="/sessions/new">
|
<Link href="/sessions/new">
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<span>📊</span>
|
<span>📊</span>
|
||||||
@@ -120,11 +150,23 @@ export default async function SessionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/year-review/new">
|
<Link href="/year-review/new">
|
||||||
<Button>
|
<Button variant="outline">
|
||||||
<span>📅</span>
|
<span>📅</span>
|
||||||
Créer un Year Review
|
Créer un Year Review
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/weekly-checkin/new">
|
||||||
|
<Button 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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -133,6 +175,8 @@ export default async function SessionsPage() {
|
|||||||
swotSessions={allSwotSessions}
|
swotSessions={allSwotSessions}
|
||||||
motivatorSessions={allMotivatorSessions}
|
motivatorSessions={allMotivatorSessions}
|
||||||
yearReviewSessions={allYearReviewSessions}
|
yearReviewSessions={allYearReviewSessions}
|
||||||
|
weeklyCheckInSessions={allWeeklyCheckInSessions}
|
||||||
|
weatherSessions={allWeatherSessions}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|||||||
102
src/app/weather/[id]/page.tsx
Normal file
102
src/app/weather/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/app/weather/new/page.tsx
Normal file
147
src/app/weather/new/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } 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(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0])));
|
||||||
|
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const date = selectedDate ? new Date(selectedDate) : 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const newDate = e.target.value;
|
||||||
|
setSelectedDate(newDate);
|
||||||
|
// Only update title if user hasn't manually modified it
|
||||||
|
if (!isTitleManuallyEdited) {
|
||||||
|
setTitle(getWeekYearLabel(new Date(newDate)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
setIsTitleManuallyEdited(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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={handleTitleChange}
|
||||||
|
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"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/app/weekly-checkin/[id]/page.tsx
Normal file
101
src/app/weekly-checkin/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/app/weekly-checkin/new/page.tsx
Normal file
162
src/app/weekly-checkin/new/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||||
|
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(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0])));
|
||||||
|
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
|
||||||
|
|
||||||
|
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 date = selectedDate ? new Date(selectedDate) : 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const newDate = e.target.value;
|
||||||
|
setSelectedDate(newDate);
|
||||||
|
// Only update title if user hasn't manually modified it
|
||||||
|
if (!isTitleManuallyEdited) {
|
||||||
|
setTitle(getWeekYearLabel(new Date(newDate)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
setIsTitleManuallyEdited(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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={handleTitleChange}
|
||||||
|
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"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
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'est bien passé</strong> : Notez les réussites et points
|
||||||
|
positifs de la semaine
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Ce qui s'est mal passé</strong> : Identifiez les difficultés et
|
||||||
|
points d'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { OKRCard } from './OKRCard';
|
import { OKRCard } from './OKRCard';
|
||||||
import { Card, ToggleGroup } from '@/components/ui';
|
import { Card, ToggleGroup, Button } from '@/components/ui';
|
||||||
import { getGravatarUrl } from '@/lib/gravatar';
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
|
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod } from '@/lib/okr-utils';
|
||||||
import type { OKR } from '@/lib/types';
|
import type { OKR } from '@/lib/types';
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'grouped';
|
type ViewMode = 'grid' | 'grouped';
|
||||||
@@ -33,6 +34,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
|||||||
}
|
}
|
||||||
return 'detailed';
|
return 'detailed';
|
||||||
});
|
});
|
||||||
|
const [showAllPeriods, setShowAllPeriods] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -40,8 +42,21 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
|||||||
}
|
}
|
||||||
}, [cardViewMode]);
|
}, [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
|
// Flatten OKRs for grid view
|
||||||
const allOKRs = okrsData.flatMap((tm) =>
|
const allOKRs = filteredOKRsData.flatMap((tm) =>
|
||||||
tm.okrs.map((okr) => ({
|
tm.okrs.map((okr) => ({
|
||||||
...okr,
|
...okr,
|
||||||
teamMember: {
|
teamMember: {
|
||||||
@@ -52,11 +67,37 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
|||||||
|
|
||||||
if (allOKRs.length === 0) {
|
if (allOKRs.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
<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">
|
<Card className="p-12 text-center">
|
||||||
<div className="text-5xl mb-4">🎯</div>
|
<div className="text-5xl mb-4">🎯</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3>
|
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||||
<p className="text-muted">Aucun OKR n'a encore été défini pour cette équipe</p>
|
{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>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +105,20 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
|||||||
<div>
|
<div>
|
||||||
{/* View Toggle */}
|
{/* View Toggle */}
|
||||||
<div className="mb-6 flex items-center justify-between gap-4">
|
<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">
|
<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
|
<ToggleGroup
|
||||||
value={cardViewMode}
|
value={cardViewMode}
|
||||||
onChange={setCardViewMode}
|
onChange={setCardViewMode}
|
||||||
@@ -140,7 +193,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
|||||||
{/* Grouped View */}
|
{/* Grouped View */}
|
||||||
{viewMode === 'grouped' ? (
|
{viewMode === 'grouped' ? (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{okrsData
|
{filteredOKRsData
|
||||||
.filter((tm) => tm.okrs.length > 0)
|
.filter((tm) => tm.okrs.length > 0)
|
||||||
.map((teamMember) => (
|
.map((teamMember) => (
|
||||||
<div key={teamMember.user.id}>
|
<div key={teamMember.user.id}>
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ export { OKRForm } from './OKRForm';
|
|||||||
export { KeyResultItem } from './KeyResultItem';
|
export { KeyResultItem } from './KeyResultItem';
|
||||||
export { OKRsList } from './OKRsList';
|
export { OKRsList } from './OKRsList';
|
||||||
export { ObjectivesList } from './ObjectivesList';
|
export { ObjectivesList } from './ObjectivesList';
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export { SwotBoard } from './SwotBoard';
|
|||||||
export { SwotQuadrant } from './SwotQuadrant';
|
export { SwotQuadrant } from './SwotQuadrant';
|
||||||
export { SwotCard } from './SwotCard';
|
export { SwotCard } from './SwotCard';
|
||||||
export { ActionPanel } from './ActionPanel';
|
export { ActionPanel } from './ActionPanel';
|
||||||
export { QuadrantHelp } from './QuadrantHelp';
|
export { QuadrantHelp, QuadrantHelpPanel } from './QuadrantHelp';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { TeamCard } from './TeamCard';
|
export { TeamCard } from './TeamCard';
|
||||||
|
export { TeamDetailClient } from './TeamDetailClient';
|
||||||
export { MembersList } from './MembersList';
|
export { MembersList } from './MembersList';
|
||||||
export { AddMemberModal } from './AddMemberModal';
|
export { AddMemberModal } from './AddMemberModal';
|
||||||
export { DeleteTeamButton } from './DeleteTeamButton';
|
export { DeleteTeamButton } from './DeleteTeamButton';
|
||||||
|
|
||||||
|
|||||||
28
src/components/ui/EditableWeatherTitle.tsx
Normal file
28
src/components/ui/EditableWeatherTitle.tsx
Normal 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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/ui/EditableWeeklyCheckInTitle.tsx
Normal file
28
src/components/ui/EditableWeeklyCheckInTitle.tsx
Normal 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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ export { EditableTitle } from './EditableTitle';
|
|||||||
export { EditableSessionTitle } from './EditableSessionTitle';
|
export { EditableSessionTitle } from './EditableSessionTitle';
|
||||||
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
||||||
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
||||||
|
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
|
||||||
|
export { EditableWeatherTitle } from './EditableWeatherTitle';
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
export { Modal, ModalFooter } from './Modal';
|
export { Modal, ModalFooter } from './Modal';
|
||||||
export { Select } from './Select';
|
export { Select } from './Select';
|
||||||
|
|||||||
146
src/components/weather/WeatherBoard.tsx
Normal file
146
src/components/weather/WeatherBoard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
src/components/weather/WeatherCard.tsx
Normal file
283
src/components/weather/WeatherCard.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition, useEffect } 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;
|
||||||
|
|
||||||
|
// Sync local state with props when they change (e.g., from SSE refresh)
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setNotes(entry.notes || '');
|
||||||
|
setPerformanceEmoji(entry.performanceEmoji || null);
|
||||||
|
setMoralEmoji(entry.moralEmoji || null);
|
||||||
|
setFluxEmoji(entry.fluxEmoji || null);
|
||||||
|
setValueCreationEmoji(entry.valueCreationEmoji || null);
|
||||||
|
}, [entry.notes, entry.performanceEmoji, entry.moralEmoji, entry.fluxEmoji, entry.valueCreationEmoji]);
|
||||||
|
|
||||||
|
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="w-24 px-2 py-3">
|
||||||
|
{canEditThis ? (
|
||||||
|
<div className="relative mx-auto w-fit">
|
||||||
|
<select
|
||||||
|
value={performanceEmoji || ''}
|
||||||
|
onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
|
||||||
|
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center 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 }) => (
|
||||||
|
<option key={emoji || 'none'} value={emoji}>
|
||||||
|
{emoji}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 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="w-24 px-2 py-3">
|
||||||
|
{canEditThis ? (
|
||||||
|
<div className="relative mx-auto w-fit">
|
||||||
|
<select
|
||||||
|
value={moralEmoji || ''}
|
||||||
|
onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
|
||||||
|
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center 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 }) => (
|
||||||
|
<option key={emoji || 'none'} value={emoji}>
|
||||||
|
{emoji}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 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="w-24 px-2 py-3">
|
||||||
|
{canEditThis ? (
|
||||||
|
<div className="relative mx-auto w-fit">
|
||||||
|
<select
|
||||||
|
value={fluxEmoji || ''}
|
||||||
|
onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
|
||||||
|
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center 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 }) => (
|
||||||
|
<option key={emoji || 'none'} value={emoji}>
|
||||||
|
{emoji}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 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="w-24 px-2 py-3">
|
||||||
|
{canEditThis ? (
|
||||||
|
<div className="relative mx-auto w-fit">
|
||||||
|
<select
|
||||||
|
value={valueCreationEmoji || ''}
|
||||||
|
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
|
||||||
|
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center 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 }) => (
|
||||||
|
<option key={emoji || 'none'} value={emoji}>
|
||||||
|
{emoji}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 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-[400px]">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/weather/WeatherInfoPanel.tsx
Normal file
56
src/components/weather/WeatherInfoPanel.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/weather/WeatherLiveWrapper.tsx
Normal file
142
src/components/weather/WeatherLiveWrapper.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
src/components/weather/WeatherShareModal.tsx
Normal file
307
src/components/weather/WeatherShareModal.tsx
Normal 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'êtes membre d'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'é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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/weather/index.ts
Normal file
5
src/components/weather/index.ts
Normal 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';
|
||||||
161
src/components/weekly-checkin/CurrentQuarterOKRs.tsx
Normal file
161
src/components/weekly-checkin/CurrentQuarterOKRs.tsx
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/components/weekly-checkin/WeeklyCheckInBoard.tsx
Normal file
94
src/components/weekly-checkin/WeeklyCheckInBoard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/components/weekly-checkin/WeeklyCheckInCard.tsx
Normal file
200
src/components/weekly-checkin/WeeklyCheckInCard.tsx
Normal 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';
|
||||||
132
src/components/weekly-checkin/WeeklyCheckInLiveWrapper.tsx
Normal file
132
src/components/weekly-checkin/WeeklyCheckInLiveWrapper.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/components/weekly-checkin/WeeklyCheckInSection.tsx
Normal file
173
src/components/weekly-checkin/WeeklyCheckInSection.tsx
Normal 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';
|
||||||
172
src/components/weekly-checkin/WeeklyCheckInShareModal.tsx
Normal file
172
src/components/weekly-checkin/WeeklyCheckInShareModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/components/weekly-checkin/index.ts
Normal file
6
src/components/weekly-checkin/index.ts
Normal 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';
|
||||||
31
src/hooks/useWeatherLive.ts
Normal file
31
src/hooks/useWeatherLive.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
src/hooks/useWeeklyCheckInLive.ts
Normal file
31
src/hooks/useWeeklyCheckInLive.ts
Normal 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
21
src/lib/date-utils.ts
Normal 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
58
src/lib/okr-utils.ts
Normal 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);
|
||||||
|
}
|
||||||
216
src/lib/types.ts
216
src/lib/types.ts
@@ -571,3 +571,219 @@ function generatePeriodSuggestions(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PERIOD_SUGGESTIONS = generatePeriodSuggestions();
|
export const PERIOD_SUGGESTIONS = generatePeriodSuggestions();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Weekly Check-in - Type Definitions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type WeeklyCheckInCategory =
|
||||||
|
| 'WENT_WELL' // Ce qui s'est bien passé
|
||||||
|
| 'WENT_WRONG' // Ce qui s'est mal passé
|
||||||
|
| 'CURRENT_FOCUS' // Les enjeux du moment (je me concentre sur ...)
|
||||||
|
| 'NEXT_FOCUS'; // Les prochains enjeux
|
||||||
|
|
||||||
|
export type Emotion =
|
||||||
|
| 'PRIDE' // Fierté
|
||||||
|
| 'JOY' // Joie
|
||||||
|
| 'SATISFACTION' // Satisfaction
|
||||||
|
| 'GRATITUDE' // Gratitude
|
||||||
|
| 'CONFIDENCE' // Confiance
|
||||||
|
| 'FRUSTRATION' // Frustration
|
||||||
|
| 'WORRY' // Inquiétude
|
||||||
|
| 'DISAPPOINTMENT' // Déception
|
||||||
|
| 'EXCITEMENT' // Excitement
|
||||||
|
| 'ANTICIPATION' // Anticipation
|
||||||
|
| 'DETERMINATION' // Détermination
|
||||||
|
| 'NONE'; // Aucune émotion
|
||||||
|
|
||||||
|
export interface WeeklyCheckInItem {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
category: WeeklyCheckInCategory;
|
||||||
|
emotion: Emotion;
|
||||||
|
order: number;
|
||||||
|
sessionId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyCheckInSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
participant: string;
|
||||||
|
date: Date;
|
||||||
|
userId: string;
|
||||||
|
items: WeeklyCheckInItem[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWeeklyCheckInSessionInput {
|
||||||
|
title: string;
|
||||||
|
participant: string;
|
||||||
|
date?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWeeklyCheckInSessionInput {
|
||||||
|
title?: string;
|
||||||
|
participant?: string;
|
||||||
|
date?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWeeklyCheckInItemInput {
|
||||||
|
content: string;
|
||||||
|
category: WeeklyCheckInCategory;
|
||||||
|
emotion?: Emotion;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWeeklyCheckInItemInput {
|
||||||
|
content?: string;
|
||||||
|
category?: WeeklyCheckInCategory;
|
||||||
|
emotion?: Emotion;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Weekly Check-in - UI Config
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface WeeklyCheckInSectionConfig {
|
||||||
|
category: WeeklyCheckInCategory;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEEKLY_CHECK_IN_SECTIONS: WeeklyCheckInSectionConfig[] = [
|
||||||
|
{
|
||||||
|
category: 'WENT_WELL',
|
||||||
|
title: 'Ce qui s\'est bien passé',
|
||||||
|
icon: '✅',
|
||||||
|
description: 'Les réussites et points positifs de la semaine',
|
||||||
|
color: '#22c55e', // green
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'WENT_WRONG',
|
||||||
|
title: 'Ce qui s\'est mal passé',
|
||||||
|
icon: '⚠️',
|
||||||
|
description: 'Les difficultés et points d\'amélioration',
|
||||||
|
color: '#ef4444', // red
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'CURRENT_FOCUS',
|
||||||
|
title: 'Enjeux du moment',
|
||||||
|
icon: '🎯',
|
||||||
|
description: 'Sur quoi je me concentre actuellement',
|
||||||
|
color: '#3b82f6', // blue
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'NEXT_FOCUS',
|
||||||
|
title: 'Prochains enjeux',
|
||||||
|
icon: '🚀',
|
||||||
|
description: 'Ce sur quoi je vais me concentrer prochainement',
|
||||||
|
color: '#8b5cf6', // purple
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WEEKLY_CHECK_IN_BY_CATEGORY: Record<
|
||||||
|
WeeklyCheckInCategory,
|
||||||
|
WeeklyCheckInSectionConfig
|
||||||
|
> = WEEKLY_CHECK_IN_SECTIONS.reduce(
|
||||||
|
(acc, config) => {
|
||||||
|
acc[config.category] = config;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<WeeklyCheckInCategory, WeeklyCheckInSectionConfig>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface EmotionConfig {
|
||||||
|
emotion: Emotion;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMOTIONS_CONFIG: EmotionConfig[] = [
|
||||||
|
{
|
||||||
|
emotion: 'PRIDE',
|
||||||
|
label: 'Fierté',
|
||||||
|
icon: '🦁',
|
||||||
|
color: '#f59e0b', // amber
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'JOY',
|
||||||
|
label: 'Joie',
|
||||||
|
icon: '😊',
|
||||||
|
color: '#eab308', // yellow
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'SATISFACTION',
|
||||||
|
label: 'Satisfaction',
|
||||||
|
icon: '😌',
|
||||||
|
color: '#22c55e', // green
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'GRATITUDE',
|
||||||
|
label: 'Gratitude',
|
||||||
|
icon: '🙏',
|
||||||
|
color: '#14b8a6', // teal
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'CONFIDENCE',
|
||||||
|
label: 'Confiance',
|
||||||
|
icon: '💪',
|
||||||
|
color: '#3b82f6', // blue
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'FRUSTRATION',
|
||||||
|
label: 'Frustration',
|
||||||
|
icon: '😤',
|
||||||
|
color: '#f97316', // orange
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'WORRY',
|
||||||
|
label: 'Inquiétude',
|
||||||
|
icon: '😟',
|
||||||
|
color: '#eab308', // yellow
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'DISAPPOINTMENT',
|
||||||
|
label: 'Déception',
|
||||||
|
icon: '😞',
|
||||||
|
color: '#ef4444', // red
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'EXCITEMENT',
|
||||||
|
label: 'Excitement',
|
||||||
|
icon: '🤩',
|
||||||
|
color: '#ec4899', // pink
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'ANTICIPATION',
|
||||||
|
label: 'Anticipation',
|
||||||
|
icon: '⏳',
|
||||||
|
color: '#8b5cf6', // purple
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'DETERMINATION',
|
||||||
|
label: 'Détermination',
|
||||||
|
icon: '🔥',
|
||||||
|
color: '#dc2626', // red
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emotion: 'NONE',
|
||||||
|
label: 'Aucune',
|
||||||
|
icon: '—',
|
||||||
|
color: '#6b7280', // gray
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMOTION_BY_TYPE: Record<Emotion, EmotionConfig> = EMOTIONS_CONFIG.reduce(
|
||||||
|
(acc, config) => {
|
||||||
|
acc[config.emotion] = config;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<Emotion, EmotionConfig>
|
||||||
|
);
|
||||||
|
|||||||
@@ -183,6 +183,45 @@ export async function getUserOKRs(userId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserOKRsForPeriod(userId: string, period: string) {
|
||||||
|
// Get all team members for this user
|
||||||
|
const teamMembers = await prisma.teamMember.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
okrs: {
|
||||||
|
where: {
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
keyResults: {
|
||||||
|
orderBy: {
|
||||||
|
order: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten and return OKRs with team info
|
||||||
|
return teamMembers.flatMap((tm) =>
|
||||||
|
tm.okrs.map((okr) => ({
|
||||||
|
...okr,
|
||||||
|
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||||
|
team: tm.team,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateOKR(
|
export async function updateOKR(
|
||||||
okrId: string,
|
okrId: string,
|
||||||
data: UpdateOKRInput,
|
data: UpdateOKRInput,
|
||||||
|
|||||||
388
src/services/weather.ts
Normal file
388
src/services/weather.ts
Normal 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;
|
||||||
|
}
|
||||||
371
src/services/weekly-checkin.ts
Normal file
371
src/services/weekly-checkin.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user