Compare commits
2 Commits
47703db348
...
53ee344ae7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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");
|
||||||
@@ -25,6 +25,10 @@ model User {
|
|||||||
yearReviewSessions YearReviewSession[]
|
yearReviewSessions YearReviewSession[]
|
||||||
sharedYearReviewSessions YRSessionShare[]
|
sharedYearReviewSessions YRSessionShare[]
|
||||||
yearReviewSessionEvents YRSessionEvent[]
|
yearReviewSessionEvents YRSessionEvent[]
|
||||||
|
// Weekly Check-in relations
|
||||||
|
weeklyCheckInSessions WeeklyCheckInSession[]
|
||||||
|
sharedWeeklyCheckInSessions WCISessionShare[]
|
||||||
|
weeklyCheckInSessionEvents WCISessionEvent[]
|
||||||
// Teams & OKRs relations
|
// Teams & OKRs relations
|
||||||
createdTeams Team[]
|
createdTeams Team[]
|
||||||
teamMembers TeamMember[]
|
teamMembers TeamMember[]
|
||||||
@@ -365,3 +369,88 @@ model KeyResult {
|
|||||||
@@index([okrId])
|
@@index([okrId])
|
||||||
@@index([okrId, order])
|
@@index([okrId, order])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Weekly Check-in Workshop
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum WeeklyCheckInCategory {
|
||||||
|
WENT_WELL // Ce qui s'est bien passé
|
||||||
|
WENT_WRONG // Ce qui s'est mal passé
|
||||||
|
CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...)
|
||||||
|
NEXT_FOCUS // Les prochains enjeux
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Emotion {
|
||||||
|
PRIDE // Fierté
|
||||||
|
JOY // Joie
|
||||||
|
SATISFACTION // Satisfaction
|
||||||
|
GRATITUDE // Gratitude
|
||||||
|
CONFIDENCE // Confiance
|
||||||
|
FRUSTRATION // Frustration
|
||||||
|
WORRY // Inquiétude
|
||||||
|
DISAPPOINTMENT // Déception
|
||||||
|
EXCITEMENT // Excitement
|
||||||
|
ANTICIPATION // Anticipation
|
||||||
|
DETERMINATION // Détermination
|
||||||
|
NONE // Aucune émotion
|
||||||
|
}
|
||||||
|
|
||||||
|
model WeeklyCheckInSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
participant String // Nom du participant
|
||||||
|
date DateTime @default(now())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
items WeeklyCheckInItem[]
|
||||||
|
shares WCISessionShare[]
|
||||||
|
events WCISessionEvent[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([date])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WeeklyCheckInItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
content String
|
||||||
|
category WeeklyCheckInCategory
|
||||||
|
emotion Emotion @default(NONE)
|
||||||
|
order Int @default(0)
|
||||||
|
sessionId String
|
||||||
|
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([sessionId, category])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WCISessionShare {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
role ShareRole @default(EDITOR)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([sessionId, userId])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WCISessionEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
|
||||||
|
payload String // JSON payload
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([sessionId, createdAt])
|
||||||
|
}
|
||||||
|
|||||||
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/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/app/page.tsx
104
src/app/page.tsx
@@ -68,6 +68,22 @@ export default function Home() {
|
|||||||
accentColor="#f59e0b"
|
accentColor="#f59e0b"
|
||||||
newHref="/year-review/new"
|
newHref="/year-review/new"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Weekly Check-in Workshop Card */}
|
||||||
|
<WorkshopCard
|
||||||
|
href="/sessions?tab=weekly-checkin"
|
||||||
|
icon="📝"
|
||||||
|
title="Weekly Check-in"
|
||||||
|
tagline="Le point hebdomadaire avec vos collaborateurs"
|
||||||
|
description="Chaque semaine, faites le point avec vos collaborateurs sur ce qui s'est bien passé, ce qui s'est mal passé, les enjeux du moment et les prochains enjeux."
|
||||||
|
features={[
|
||||||
|
'4 catégories : Bien passé, Mal passé, Enjeux du moment, Prochains enjeux',
|
||||||
|
'Ajout d\'émotions à chaque item (fierté, joie, frustration, etc.)',
|
||||||
|
'Suivi hebdomadaire régulier',
|
||||||
|
]}
|
||||||
|
accentColor="#10b981"
|
||||||
|
newHref="/weekly-checkin/new"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -355,6 +371,94 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Weekly Check-in Deep Dive Section */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<span className="text-4xl">📝</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2>
|
||||||
|
<p className="text-green-500 font-medium">Le point hebdomadaire avec vos collaborateurs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 lg:grid-cols-2">
|
||||||
|
{/* Why */}
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||||
|
<span className="text-2xl">💡</span>
|
||||||
|
Pourquoi faire un check-in hebdomadaire ?
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted mb-4">
|
||||||
|
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier
|
||||||
|
avec vos collaborateurs. Il favorise la communication, l'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>
|
||||||
|
|
||||||
{/* 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,18 @@ import {
|
|||||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
||||||
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
|
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
|
||||||
|
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||||
|
|
||||||
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson';
|
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'weekly-checkin' | 'byPerson';
|
||||||
|
|
||||||
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson'];
|
const VALID_TABS: WorkshopType[] = [
|
||||||
|
'all',
|
||||||
|
'swot',
|
||||||
|
'motivators',
|
||||||
|
'year-review',
|
||||||
|
'weekly-checkin',
|
||||||
|
'byPerson',
|
||||||
|
];
|
||||||
|
|
||||||
interface ShareUser {
|
interface ShareUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,12 +92,28 @@ interface YearReviewSession {
|
|||||||
workshopType: 'year-review';
|
workshopType: 'year-review';
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnySession = SwotSession | MotivatorSession | YearReviewSession;
|
interface WeeklyCheckInSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
participant: string;
|
||||||
|
resolvedParticipant: ResolvedCollaborator;
|
||||||
|
date: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
isOwner: boolean;
|
||||||
|
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||||
|
user: { id: string; name: string | null; email: string };
|
||||||
|
shares: Share[];
|
||||||
|
_count: { items: number };
|
||||||
|
workshopType: 'weekly-checkin';
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession;
|
||||||
|
|
||||||
interface WorkshopTabsProps {
|
interface WorkshopTabsProps {
|
||||||
swotSessions: SwotSession[];
|
swotSessions: SwotSession[];
|
||||||
motivatorSessions: MotivatorSession[];
|
motivatorSessions: MotivatorSession[];
|
||||||
yearReviewSessions: YearReviewSession[];
|
yearReviewSessions: YearReviewSession[];
|
||||||
|
weeklyCheckInSessions: WeeklyCheckInSession[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get resolved collaborator from any session
|
// Helper to get resolved collaborator from any session
|
||||||
@@ -98,6 +122,8 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
|||||||
return (session as SwotSession).resolvedCollaborator;
|
return (session as SwotSession).resolvedCollaborator;
|
||||||
} else if (session.workshopType === 'year-review') {
|
} else if (session.workshopType === 'year-review') {
|
||||||
return (session as YearReviewSession).resolvedParticipant;
|
return (session as YearReviewSession).resolvedParticipant;
|
||||||
|
} else if (session.workshopType === 'weekly-checkin') {
|
||||||
|
return (session as WeeklyCheckInSession).resolvedParticipant;
|
||||||
} else {
|
} else {
|
||||||
return (session as MotivatorSession).resolvedParticipant;
|
return (session as MotivatorSession).resolvedParticipant;
|
||||||
}
|
}
|
||||||
@@ -141,6 +167,7 @@ export function WorkshopTabs({
|
|||||||
swotSessions,
|
swotSessions,
|
||||||
motivatorSessions,
|
motivatorSessions,
|
||||||
yearReviewSessions,
|
yearReviewSessions,
|
||||||
|
weeklyCheckInSessions,
|
||||||
}: WorkshopTabsProps) {
|
}: WorkshopTabsProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -165,6 +192,7 @@ export function WorkshopTabs({
|
|||||||
...swotSessions,
|
...swotSessions,
|
||||||
...motivatorSessions,
|
...motivatorSessions,
|
||||||
...yearReviewSessions,
|
...yearReviewSessions,
|
||||||
|
...weeklyCheckInSessions,
|
||||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
// Filter based on active tab (for non-byPerson tabs)
|
// Filter based on active tab (for non-byPerson tabs)
|
||||||
@@ -175,7 +203,9 @@ export function WorkshopTabs({
|
|||||||
? swotSessions
|
? swotSessions
|
||||||
: activeTab === 'motivators'
|
: activeTab === 'motivators'
|
||||||
? motivatorSessions
|
? motivatorSessions
|
||||||
: yearReviewSessions;
|
: activeTab === 'year-review'
|
||||||
|
? yearReviewSessions
|
||||||
|
: weeklyCheckInSessions;
|
||||||
|
|
||||||
// Separate by ownership
|
// Separate by ownership
|
||||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||||
@@ -226,6 +256,13 @@ export function WorkshopTabs({
|
|||||||
label="Year Review"
|
label="Year Review"
|
||||||
count={yearReviewSessions.length}
|
count={yearReviewSessions.length}
|
||||||
/>
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'weekly-checkin'}
|
||||||
|
onClick={() => setActiveTab('weekly-checkin')}
|
||||||
|
icon="📝"
|
||||||
|
label="Weekly Check-in"
|
||||||
|
count={weeklyCheckInSessions.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sessions */}
|
{/* Sessions */}
|
||||||
@@ -343,18 +380,29 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
|
|
||||||
const isSwot = session.workshopType === 'swot';
|
const isSwot = session.workshopType === 'swot';
|
||||||
const isYearReview = session.workshopType === 'year-review';
|
const isYearReview = session.workshopType === 'year-review';
|
||||||
|
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||||
const href = isSwot
|
const href = isSwot
|
||||||
? `/sessions/${session.id}`
|
? `/sessions/${session.id}`
|
||||||
: isYearReview
|
: isYearReview
|
||||||
? `/year-review/${session.id}`
|
? `/year-review/${session.id}`
|
||||||
: `/motivators/${session.id}`;
|
: isWeeklyCheckIn
|
||||||
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
|
? `/weekly-checkin/${session.id}`
|
||||||
|
: `/motivators/${session.id}`;
|
||||||
|
const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : '🎯';
|
||||||
const participant = isSwot
|
const participant = isSwot
|
||||||
? (session as SwotSession).collaborator
|
? (session as SwotSession).collaborator
|
||||||
: isYearReview
|
: isYearReview
|
||||||
? (session as YearReviewSession).participant
|
? (session as YearReviewSession).participant
|
||||||
: (session as MotivatorSession).participant;
|
: isWeeklyCheckIn
|
||||||
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6';
|
? (session as WeeklyCheckInSession).participant
|
||||||
|
: (session as MotivatorSession).participant;
|
||||||
|
const accentColor = isSwot
|
||||||
|
? '#06b6d4'
|
||||||
|
: isYearReview
|
||||||
|
? '#f59e0b'
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? '#10b981'
|
||||||
|
: '#8b5cf6';
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -362,7 +410,9 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
? await deleteSwotSession(session.id)
|
? await deleteSwotSession(session.id)
|
||||||
: isYearReview
|
: isYearReview
|
||||||
? await deleteYearReviewSession(session.id)
|
? await deleteYearReviewSession(session.id)
|
||||||
: await deleteMotivatorSession(session.id);
|
: isWeeklyCheckIn
|
||||||
|
? await deleteWeeklyCheckInSession(session.id)
|
||||||
|
: await deleteMotivatorSession(session.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
@@ -381,10 +431,15 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
title: editTitle,
|
title: editTitle,
|
||||||
participant: editParticipant,
|
participant: editParticipant,
|
||||||
})
|
})
|
||||||
: await updateMotivatorSession(session.id, {
|
: isWeeklyCheckIn
|
||||||
title: editTitle,
|
? await updateWeeklyCheckInSession(session.id, {
|
||||||
participant: editParticipant,
|
title: editTitle,
|
||||||
});
|
participant: editParticipant,
|
||||||
|
})
|
||||||
|
: await updateMotivatorSession(session.id, {
|
||||||
|
title: editTitle,
|
||||||
|
participant: editParticipant,
|
||||||
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -401,6 +456,8 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editParticipantLabel = isSwot ? 'Collaborateur' : 'Participant';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -456,6 +513,17 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Année {(session as YearReviewSession).year}</span>
|
<span>Année {(session as YearReviewSession).year}</span>
|
||||||
</>
|
</>
|
||||||
|
) : isWeeklyCheckIn ? (
|
||||||
|
<>
|
||||||
|
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>
|
||||||
|
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
||||||
)}
|
)}
|
||||||
@@ -570,7 +638,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
htmlFor="edit-participant"
|
htmlFor="edit-participant"
|
||||||
className="block text-sm font-medium text-foreground mb-1"
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
>
|
>
|
||||||
{isSwot ? 'Collaborateur' : 'Participant'}
|
{editParticipantLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-participant"
|
id="edit-participant"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth';
|
|||||||
import { getSessionsByUserId } from '@/services/sessions';
|
import { getSessionsByUserId } from '@/services/sessions';
|
||||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
||||||
import { getYearReviewSessionsByUserId } from '@/services/year-review';
|
import { getYearReviewSessionsByUserId } from '@/services/year-review';
|
||||||
|
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
|
||||||
import { Card, Button } from '@/components/ui';
|
import { Card, Button } from '@/components/ui';
|
||||||
import { WorkshopTabs } from './WorkshopTabs';
|
import { WorkshopTabs } from './WorkshopTabs';
|
||||||
|
|
||||||
@@ -33,12 +34,14 @@ export default async function SessionsPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch SWOT, Moving Motivators, and Year Review sessions
|
// Fetch SWOT, Moving Motivators, Year Review, and Weekly Check-in sessions
|
||||||
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([
|
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions] =
|
||||||
getSessionsByUserId(session.user.id),
|
await Promise.all([
|
||||||
getMotivatorSessionsByUserId(session.user.id),
|
getSessionsByUserId(session.user.id),
|
||||||
getYearReviewSessionsByUserId(session.user.id),
|
getMotivatorSessionsByUserId(session.user.id),
|
||||||
]);
|
getYearReviewSessionsByUserId(session.user.id),
|
||||||
|
getWeeklyCheckInSessionsByUserId(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
// Add type to each session for unified display
|
// Add type to each session for unified display
|
||||||
const allSwotSessions = swotSessions.map((s) => ({
|
const allSwotSessions = swotSessions.map((s) => ({
|
||||||
@@ -56,11 +59,17 @@ export default async function SessionsPage() {
|
|||||||
workshopType: 'year-review' as const,
|
workshopType: 'year-review' as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const allWeeklyCheckInSessions = weeklyCheckInSessions.map((s) => ({
|
||||||
|
...s,
|
||||||
|
workshopType: 'weekly-checkin' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
// Combine and sort by updatedAt
|
// Combine and sort by updatedAt
|
||||||
const allSessions = [
|
const allSessions = [
|
||||||
...allSwotSessions,
|
...allSwotSessions,
|
||||||
...allMotivatorSessions,
|
...allMotivatorSessions,
|
||||||
...allYearReviewSessions,
|
...allYearReviewSessions,
|
||||||
|
...allWeeklyCheckInSessions,
|
||||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
const hasNoSessions = allSessions.length === 0;
|
const hasNoSessions = allSessions.length === 0;
|
||||||
@@ -87,11 +96,17 @@ export default async function SessionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/year-review/new">
|
<Link href="/year-review/new">
|
||||||
<Button>
|
<Button variant="outline">
|
||||||
<span>📅</span>
|
<span>📅</span>
|
||||||
Nouveau Year Review
|
Nouveau Year Review
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/weekly-checkin/new">
|
||||||
|
<Button>
|
||||||
|
<span>📝</span>
|
||||||
|
Nouveau Check-in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,9 +119,10 @@ export default async function SessionsPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted mb-6 max-w-md mx-auto">
|
<p className="text-muted mb-6 max-w-md mx-auto">
|
||||||
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
|
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
|
||||||
découvrir les motivations, ou un Year Review pour faire le bilan de l'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 +136,17 @@ export default async function SessionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/year-review/new">
|
<Link href="/year-review/new">
|
||||||
<Button>
|
<Button variant="outline">
|
||||||
<span>📅</span>
|
<span>📅</span>
|
||||||
Créer un Year Review
|
Créer un Year Review
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/weekly-checkin/new">
|
||||||
|
<Button>
|
||||||
|
<span>📝</span>
|
||||||
|
Créer un Check-in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -133,6 +155,7 @@ export default async function SessionsPage() {
|
|||||||
swotSessions={allSwotSessions}
|
swotSessions={allSwotSessions}
|
||||||
motivatorSessions={allMotivatorSessions}
|
motivatorSessions={allMotivatorSessions}
|
||||||
yearReviewSessions={allYearReviewSessions}
|
yearReviewSessions={allYearReviewSessions}
|
||||||
|
weeklyCheckInSessions={allWeeklyCheckInSessions}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/app/weekly-checkin/new/page.tsx
Normal file
146
src/app/weekly-checkin/new/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||||
|
|
||||||
|
export default function NewWeeklyCheckInPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const title = formData.get('title') as string;
|
||||||
|
const participant = formData.get('participant') as string;
|
||||||
|
const dateStr = formData.get('date') as string;
|
||||||
|
const date = dateStr ? new Date(dateStr) : undefined;
|
||||||
|
|
||||||
|
if (!title || !participant) {
|
||||||
|
setError('Veuillez remplir tous les champs');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createWeeklyCheckInSession({ title, participant, date });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || 'Une erreur est survenue');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/weekly-checkin/${result.data?.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default date to today
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<span>📝</span>
|
||||||
|
Nouveau Check-in Hebdomadaire
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Créez un check-in hebdomadaire pour faire le point sur la semaine avec votre
|
||||||
|
collaborateur
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Titre du check-in"
|
||||||
|
name="title"
|
||||||
|
placeholder="Ex: Check-in semaine du 15 janvier"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nom du collaborateur"
|
||||||
|
name="participant"
|
||||||
|
placeholder="Ex: Jean Dupont"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Date du check-in
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="date"
|
||||||
|
name="date"
|
||||||
|
type="date"
|
||||||
|
defaultValue={today}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||||
|
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||||
|
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
|
||||||
|
<li>
|
||||||
|
<strong>Ce qui s'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/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,7 @@ export { EditableTitle } from './EditableTitle';
|
|||||||
export { EditableSessionTitle } from './EditableSessionTitle';
|
export { EditableSessionTitle } from './EditableSessionTitle';
|
||||||
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
||||||
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
||||||
|
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
export { Modal, ModalFooter } from './Modal';
|
export { Modal, ModalFooter } from './Modal';
|
||||||
export { Select } from './Select';
|
export { Select } from './Select';
|
||||||
|
|||||||
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/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,
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/lib/okr-utils.ts
Normal file
16
src/lib/okr-utils.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Get the current quarter period string (e.g., "Q1 2025") from a date
|
||||||
|
*/
|
||||||
|
export function getCurrentQuarterPeriod(date: Date = new Date()): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1; // 1-12
|
||||||
|
const quarter = Math.ceil(month / 3);
|
||||||
|
return `Q${quarter} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a period string matches the current quarter
|
||||||
|
*/
|
||||||
|
export function isCurrentQuarterPeriod(period: string, date: Date = new Date()): boolean {
|
||||||
|
return period === getCurrentQuarterPeriod(date);
|
||||||
|
}
|
||||||
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,
|
||||||
|
|||||||
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