feat: add team collaboration sessions for admins, enhancing session management and visibility in the application

This commit is contained in:
Julien Froidefond
2026-02-17 14:03:31 +01:00
parent 7f3eabbdb2
commit 4e14112ffa
9 changed files with 344 additions and 26 deletions

View File

@@ -28,6 +28,7 @@ import {
const TYPE_TABS = [ const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' }, { value: 'all' as const, icon: '📋', label: 'Tous' },
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })), ...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
]; ];
@@ -131,6 +132,7 @@ interface WorkshopTabsProps {
yearReviewSessions: YearReviewSession[]; yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[]; weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[]; weatherSessions: WeatherSession[];
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
} }
// Helper to get resolved collaborator from any session // Helper to get resolved collaborator from any session
@@ -197,6 +199,7 @@ export function WorkshopTabs({
yearReviewSessions, yearReviewSessions,
weeklyCheckInSessions, weeklyCheckInSessions,
weatherSessions, weatherSessions,
teamCollabSessions = [],
}: WorkshopTabsProps) { }: WorkshopTabsProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -219,7 +222,7 @@ export function WorkshopTabs({
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`); router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
}; };
// Combine and sort all sessions // Combine and sort all sessions (exclude team collab from main list - they're shown separately)
const allSessions: AnySession[] = [ const allSessions: AnySession[] = [
...swotSessions, ...swotSessions,
...motivatorSessions, ...motivatorSessions,
@@ -232,7 +235,9 @@ export function WorkshopTabs({
const filteredSessions = const filteredSessions =
activeTab === 'all' || activeTab === 'byPerson' activeTab === 'all' || activeTab === 'byPerson'
? allSessions ? allSessions
: activeTab === 'swot' : activeTab === 'team'
? teamCollabSessions
: activeTab === 'swot'
? swotSessions ? swotSessions
: activeTab === 'motivators' : activeTab === 'motivators'
? motivatorSessions ? motivatorSessions
@@ -242,9 +247,11 @@ export function WorkshopTabs({
? weeklyCheckInSessions ? weeklyCheckInSessions
: weatherSessions; : weatherSessions;
// Separate by ownership // Separate by ownership (for non-team tab: owned, shared, teamCollab)
const ownedSessions = filteredSessions.filter((s) => s.isOwner); const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner); const sharedSessions = filteredSessions.filter((s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab);
const teamCollabFiltered =
activeTab === 'all' ? teamCollabSessions : activeTab === 'team' ? teamCollabSessions : [];
// Group by person (all sessions - owned and shared) // Group by person (all sessions - owned and shared)
const sessionsByPerson = groupByPerson(allSessions); const sessionsByPerson = groupByPerson(allSessions);
@@ -270,6 +277,15 @@ export function WorkshopTabs({
label="Par personne" label="Par personne"
count={sessionsByPerson.size} count={sessionsByPerson.size}
/> />
{teamCollabSessions.length > 0 && (
<TabButton
active={activeTab === 'team'}
onClick={() => setActiveTab('team')}
icon="🏢"
label="Équipe"
count={teamCollabSessions.length}
/>
)}
<TypeFilterDropdown <TypeFilterDropdown
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
@@ -281,6 +297,7 @@ export function WorkshopTabs({
'year-review': yearReviewSessions.length, 'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length, 'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length, weather: weatherSessions.length,
team: teamCollabSessions.length,
}} }}
/> />
</div> </div>
@@ -312,6 +329,28 @@ export function WorkshopTabs({
})} })}
</div> </div>
) )
) : activeTab === 'team' ? (
teamCollabSessions.length === 0 ? (
<div className="text-center py-12 text-muted">
Aucun atelier de vos collaborateurs d&apos;équipe (non partagés avec vous)
</div>
) : (
<div className="space-y-8">
<section>
<h2 className="text-lg font-semibold text-muted mb-4">
🏢 Ateliers de l&apos;équipe non partagés ({teamCollabSessions.length})
</h2>
<p className="text-sm text-muted mb-4">
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs qui ne vous sont pas encore partagés.
</p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teamCollabSessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab />
))}
</div>
</section>
</div>
)
) : filteredSessions.length === 0 ? ( ) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div> <div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : ( ) : (
@@ -343,6 +382,20 @@ export function WorkshopTabs({
</div> </div>
</section> </section>
)} )}
{/* Team collab sessions (non-shared) - grayed out, admin view only */}
{activeTab === 'all' && teamCollabFiltered.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-muted mb-4">
🏢 Équipe non partagés ({teamCollabFiltered.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teamCollabFiltered.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab />
))}
</div>
</section>
)}
</div> </div>
)} )}
</div> </div>
@@ -470,7 +523,7 @@ function TabButton({
); );
} }
function SessionCard({ session }: { session: AnySession }) { function SessionCard({ session, isTeamCollab = false }: { session: AnySession; isTeamCollab?: boolean }) {
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -562,11 +615,8 @@ function SessionCard({ session }: { session: AnySession }) {
const editParticipantLabel = workshop.participantLabel; const editParticipantLabel = workshop.participantLabel;
return ( const cardContent = (
<> <Card hover={!isTeamCollab} className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}>
<div className="relative group">
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */} {/* Accent bar */}
<div <div
className="absolute top-0 left-0 right-0 h-1" className="absolute top-0 left-0 right-0 h-1"
@@ -677,7 +727,21 @@ function SessionCard({ session }: { session: AnySession }) {
</div> </div>
)} )}
</Card> </Card>
</Link> );
return (
<>
<div className="relative group">
{isTeamCollab ? (
<div
className="cursor-default"
title="Atelier non partagé avec vous visible en tant qu'admin d'équipe"
>
{cardContent}
</div>
) : (
<Link href={href}>{cardContent}</Link>
)}
{/* Action buttons - only for owner */} {/* Action buttons - only for owner */}
{session.isOwner && ( {session.isOwner && (

View File

@@ -1,10 +1,25 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions'; import {
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; getSessionsByUserId,
import { getYearReviewSessionsByUserId } from '@/services/year-review'; getTeamCollaboratorSessionsForAdmin as getTeamSwotSessions,
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin'; } from '@/services/sessions';
import { getWeatherSessionsByUserId } from '@/services/weather'; import {
getMotivatorSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamMotivatorSessions,
} from '@/services/moving-motivators';
import {
getYearReviewSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamYearReviewSessions,
} from '@/services/year-review';
import {
getWeeklyCheckInSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeeklyCheckInSessions,
} from '@/services/weekly-checkin';
import {
getWeatherSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions,
} from '@/services/weather';
import { Card } from '@/components/ui'; import { Card } from '@/components/ui';
import { withWorkshopType } from '@/lib/workshops'; import { withWorkshopType } from '@/lib/workshops';
import { WorkshopTabs } from './WorkshopTabs'; import { WorkshopTabs } from './WorkshopTabs';
@@ -36,15 +51,30 @@ export default async function SessionsPage() {
return null; return null;
} }
// Fetch SWOT, Moving Motivators, Year Review, Weekly Check-in, and Weather sessions // Fetch sessions (owned + shared) and team collab sessions (for team admins, non-shared)
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] = const [
await Promise.all([ swotSessions,
getSessionsByUserId(session.user.id), motivatorSessions,
getMotivatorSessionsByUserId(session.user.id), yearReviewSessions,
getYearReviewSessionsByUserId(session.user.id), weeklyCheckInSessions,
getWeeklyCheckInSessionsByUserId(session.user.id), weatherSessions,
getWeatherSessionsByUserId(session.user.id), teamSwotSessions,
]); teamMotivatorSessions,
teamYearReviewSessions,
teamWeeklyCheckInSessions,
teamWeatherSessions,
] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id),
getTeamSwotSessions(session.user.id),
getTeamMotivatorSessions(session.user.id),
getTeamYearReviewSessions(session.user.id),
getTeamWeeklyCheckInSessions(session.user.id),
getTeamWeatherSessions(session.user.id),
]);
// Add workshopType to each session for unified display // Add workshopType to each session for unified display
const allSwotSessions = withWorkshopType(swotSessions, 'swot'); const allSwotSessions = withWorkshopType(swotSessions, 'swot');
@@ -53,6 +83,12 @@ export default async function SessionsPage() {
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin'); const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin');
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather'); const allWeatherSessions = withWorkshopType(weatherSessions, 'weather');
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
// Combine and sort by updatedAt // Combine and sort by updatedAt
const allSessions = [ const allSessions = [
...allSwotSessions, ...allSwotSessions,
@@ -99,6 +135,13 @@ export default async function SessionsPage() {
yearReviewSessions={allYearReviewSessions} yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions} weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions} weatherSessions={allWeatherSessions}
teamCollabSessions={[
...teamSwotWithType,
...teamMotivatorWithType,
...teamYearReviewWithType,
...teamWeeklyCheckInWithType,
...teamWeatherWithType,
]}
/> />
</Suspense> </Suspense>
)} )}

View File

@@ -13,12 +13,13 @@ export const WORKSHOP_TYPE_IDS = [
export type WorkshopTypeId = (typeof WORKSHOP_TYPE_IDS)[number]; export type WorkshopTypeId = (typeof WORKSHOP_TYPE_IDS)[number];
export type WorkshopTabType = WorkshopTypeId | 'all' | 'byPerson'; export type WorkshopTabType = WorkshopTypeId | 'all' | 'byPerson' | 'team';
export const VALID_TAB_PARAMS: WorkshopTabType[] = [ export const VALID_TAB_PARAMS: WorkshopTabType[] = [
'all', 'all',
...WORKSHOP_TYPE_IDS, ...WORKSHOP_TYPE_IDS,
'byPerson', 'byPerson',
'team',
]; ];
export interface WorkshopConfig { export interface WorkshopConfig {

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; import { resolveCollaborator } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import type { ShareRole, MotivatorType } from '@prisma/client'; import type { ShareRole, MotivatorType } from '@prisma/client';
// ============================================ // ============================================
@@ -76,6 +77,43 @@ export async function getMotivatorSessionsByUserId(userId: string) {
return sessionsWithResolved; return sessionsWithResolved;
} }
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId);
if (teamMemberIds.length === 0) return [];
const sessions = await prisma.movingMotivatorsSession.findMany({
where: {
userId: { in: teamMemberIds },
shares: { none: { userId } },
},
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: { select: { cards: true } },
},
orderBy: { updatedAt: 'desc' },
});
const withRole = sessions.map((s) => ({
...s,
isOwner: false as const,
role: 'VIEWER' as const,
isTeamCollab: true as const,
}));
return Promise.all(
withRole.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
}
export async function getMotivatorSessionById(sessionId: string, userId: string) { export async function getMotivatorSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared // Check if user owns the session OR has it shared
const session = await prisma.movingMotivatorsSession.findFirst({ const session = await prisma.movingMotivatorsSession.findFirst({

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; import { resolveCollaborator } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import type { SwotCategory, ShareRole } from '@prisma/client'; import type { SwotCategory, ShareRole } from '@prisma/client';
// ============================================ // ============================================
@@ -78,6 +79,48 @@ export async function getSessionsByUserId(userId: string) {
return sessionsWithResolved; return sessionsWithResolved;
} }
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId);
if (teamMemberIds.length === 0) return [];
const sessions = await prisma.session.findMany({
where: {
userId: { in: teamMemberIds },
shares: { none: { userId } },
},
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
actions: true,
},
},
},
orderBy: { updatedAt: 'desc' },
});
const withRole = sessions.map((s) => ({
...s,
isOwner: false as const,
role: 'VIEWER' as const,
isTeamCollab: true as const,
}));
return Promise.all(
withRole.map(async (s) => ({
...s,
resolvedCollaborator: await resolveCollaborator(s.collaborator),
}))
);
}
export async function getSessionById(sessionId: string, userId: string) { export async function getSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared // Check if user owns the session OR has it shared
const session = await prisma.session.findFirst({ const session = await prisma.session.findFirst({

View File

@@ -244,6 +244,28 @@ export async function getTeamMember(teamId: string, userId: string) {
}); });
} }
/** Returns user IDs of all members in teams where the given user is ADMIN (excluding self). */
export async function getTeamMemberIdsForAdminTeams(userId: string): Promise<string[]> {
const adminTeams = await prisma.teamMember.findMany({
where: {
userId,
role: 'ADMIN',
},
select: { teamId: true },
});
if (adminTeams.length === 0) return [];
const members = await prisma.teamMember.findMany({
where: {
teamId: { in: adminTeams.map((t) => t.teamId) },
userId: { not: userId },
},
select: { userId: true },
distinct: ['userId'],
});
return members.map((m) => m.userId);
}
export async function getTeamMemberById(teamMemberId: string) { export async function getTeamMemberById(teamMemberId: string) {
return prisma.teamMember.findUnique({ return prisma.teamMember.findUnique({
where: { id: teamMemberId }, where: { id: teamMemberId },

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import type { ShareRole } from '@prisma/client'; import type { ShareRole } from '@prisma/client';
// ============================================ // ============================================
@@ -67,6 +68,36 @@ export async function getWeatherSessionsByUserId(userId: string) {
return allSessions; return allSessions;
} }
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId);
if (teamMemberIds.length === 0) return [];
const sessions = await prisma.weatherSession.findMany({
where: {
userId: { in: teamMemberIds },
shares: { none: { userId } },
},
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: { select: { entries: true } },
},
orderBy: { updatedAt: 'desc' },
});
return sessions.map((s) => ({
...s,
isOwner: false as const,
role: 'VIEWER' as const,
isTeamCollab: true as const,
}));
}
export async function getWeatherSessionById(sessionId: string, userId: string) { export async function getWeatherSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared // Check if user owns the session OR has it shared
const session = await prisma.weatherSession.findFirst({ const session = await prisma.weatherSession.findFirst({

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; import { resolveCollaborator } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import type { ShareRole, WeeklyCheckInCategory, Emotion } from '@prisma/client'; import type { ShareRole, WeeklyCheckInCategory, Emotion } from '@prisma/client';
// ============================================ // ============================================
@@ -76,6 +77,43 @@ export async function getWeeklyCheckInSessionsByUserId(userId: string) {
return sessionsWithResolved; return sessionsWithResolved;
} }
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId);
if (teamMemberIds.length === 0) return [];
const sessions = await prisma.weeklyCheckInSession.findMany({
where: {
userId: { in: teamMemberIds },
shares: { none: { 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' },
});
const withRole = sessions.map((s) => ({
...s,
isOwner: false as const,
role: 'VIEWER' as const,
isTeamCollab: true as const,
}));
return Promise.all(
withRole.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
}
export async function getWeeklyCheckInSessionById(sessionId: string, userId: string) { export async function getWeeklyCheckInSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared // Check if user owns the session OR has it shared
const session = await prisma.weeklyCheckInSession.findFirst({ const session = await prisma.weeklyCheckInSession.findFirst({

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth'; import { resolveCollaborator } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import type { ShareRole, YearReviewCategory } from '@prisma/client'; import type { ShareRole, YearReviewCategory } from '@prisma/client';
// ============================================ // ============================================
@@ -76,6 +77,43 @@ export async function getYearReviewSessionsByUserId(userId: string) {
return sessionsWithResolved; return sessionsWithResolved;
} }
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
const teamMemberIds = await getTeamMemberIdsForAdminTeams(userId);
if (teamMemberIds.length === 0) return [];
const sessions = await prisma.yearReviewSession.findMany({
where: {
userId: { in: teamMemberIds },
shares: { none: { 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' },
});
const withRole = sessions.map((s) => ({
...s,
isOwner: false as const,
role: 'VIEWER' as const,
isTeamCollab: true as const,
}));
return Promise.all(
withRole.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
}
export async function getYearReviewSessionById(sessionId: string, userId: string) { export async function getYearReviewSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared // Check if user owns the session OR has it shared
const session = await prisma.yearReviewSession.findFirst({ const session = await prisma.yearReviewSession.findFirst({