agenda finition

This commit is contained in:
alma 2026-01-20 15:17:22 +01:00
parent 4dd5ef3ac1
commit de4cceaf3d
4 changed files with 867 additions and 13 deletions

View File

@ -0,0 +1,265 @@
# 🎨 Fonctionnalité : Modification de la couleur des calendriers de groupes
## 📋 Vue d'ensemble
Cette fonctionnalité permet aux membres d'un groupe de personnaliser la couleur de la pastille du calendrier associé à leur groupe. Chaque groupe créé possède un calendrier automatique nommé `"Groupe: {nom}"`, et cette couleur est visible dans tous les affichages de calendrier (agenda, vision, etc.).
## ✨ Fonctionnalités implémentées
### 1. **API Backend** (`/app/api/groups/[groupId]/calendar/route.ts`)
#### Nouveaux endpoints :
**GET `/api/groups/[groupId]/calendar`**
- Récupère le calendrier associé à un groupe
- Retourne l'objet calendrier avec sa couleur actuelle
**PATCH `/api/groups/[groupId]/calendar`**
- Modifie uniquement la couleur du calendrier d'un groupe
- Body : `{ "color": "#FF5733" }`
- Validation du format hexadécimal
- Vérification que l'utilisateur est membre du groupe
- Retourne le calendrier mis à jour
#### Sécurité :
- ✅ Authentification requise
- ✅ Vérification de l'appartenance au groupe via Keycloak
- ✅ Validation du format de couleur (hex : `#RRGGBB` ou `#RGB`)
---
### 2. **Interface utilisateur - Page Groupes** (`/components/groups/groups-table.tsx`)
#### Modifications :
1. **Affichage de la pastille de couleur**
- Chaque groupe affiche maintenant une pastille colorée à côté de son nom
- Couleur par défaut : `#4f46e5` (indigo)
2. **Nouveau bouton "Couleur du calendrier"**
- Accessible via le menu actions (icône ⋯)
- Icône palette (🎨)
3. **Dialog de sélection de couleur**
- Palette de 16 couleurs prédéfinies
- Input manuel pour code hexadécimal
- Aperçu en temps réel de la couleur sélectionnée
- Validation avant sauvegarde
#### Palette de couleurs disponibles :
```typescript
const colorPalette = [
"#4f46e5", // Indigo
"#0891b2", // Cyan
"#0e7490", // Teal
"#16a34a", // Green
"#65a30d", // Lime
"#ca8a04", // Amber
"#d97706", // Orange
"#dc2626", // Red
"#e11d48", // Rose
"#9333ea", // Purple
"#7c3aed", // Violet
"#2563eb", // Blue
"#0284c7", // Sky
"#059669", // Emerald
"#84cc16", // Lime
"#eab308", // Yellow
];
```
---
### 3. **Interface utilisateur - Page Missions/Équipe** (`/app/missions/equipe/page.tsx`)
#### Modifications identiques à GroupsTable :
1. **Affichage de la couleur**
- Icône du groupe (👥) affichée sur fond de la couleur du calendrier
- Texte en blanc pour meilleure lisibilité
2. **Bouton direct dans la table**
- Icône palette (🎨) directement dans les actions
- Tooltip "Couleur du calendrier"
3. **Dialog de sélection**
- Même interface que dans GroupsTable
- Sauvegarde avec loader pendant l'opération
---
## 🔄 Flux de données
### Chargement initial :
```
1. Page charge les groupes via GET /api/groups
2. Pour chaque groupe :
└─> GET /api/groups/{groupId}/calendar
└─> Récupère la couleur du calendrier
└─> Stocke dans state local : group.calendarColor
```
### Modification de couleur :
```
1. Utilisateur clique sur icône palette
2. Dialog s'ouvre avec couleur actuelle
3. Utilisateur sélectionne nouvelle couleur
4. Clic "Enregistrer"
└─> PATCH /api/groups/{groupId}/calendar { color: "#newcolor" }
└─> Vérification membre du groupe (Keycloak)
└─> Mise à jour en base de données (Prisma)
└─> Mise à jour du state local
└─> Toast de confirmation
5. Couleur visible immédiatement partout
```
---
## 🎯 Points d'affichage de la couleur
La couleur du calendrier de groupe est maintenant visible dans :
1. **`/groups`** - Page groupes
- Table des groupes (pastille à côté du nom)
2. **`/missions/equipe`** - Page gestion équipe
- Table des groupes (icône colorée)
3. **`/agenda`** - Page agenda
- Liste des calendriers (pastille à gauche)
- Événements dans le calendrier (barre colorée)
4. **`/vision`** - Page visioconférences
- Calendrier des réunions
- Événements du jour
5. **Widgets calendrier** - Tous les composants calendrier
- `calendar-widget.tsx`
- `calendar-client.tsx`
- `calendar.tsx`
---
## 🧪 Tests suggérés
### Test 1 : Modification de couleur
1. Aller sur `/groups` ou `/missions/equipe`
2. Cliquer sur ⋯ → "Couleur du calendrier"
3. Choisir une nouvelle couleur
4. Cliquer "Enregistrer"
5. ✅ Vérifier : Pastille mise à jour immédiatement
### Test 2 : Validation format
1. Ouvrir dialog couleur
2. Entrer manuellement un code invalide : `#GGGGGG`
3. Cliquer "Enregistrer"
4. ✅ Vérifier : Message d'erreur affiché
### Test 3 : Permissions
1. Se connecter avec utilisateur NON membre du groupe
2. Essayer de modifier la couleur
3. ✅ Vérifier : Erreur 403 "Vous devez être membre du groupe"
### Test 4 : Persistance
1. Modifier couleur d'un groupe
2. Actualiser la page
3. ✅ Vérifier : Couleur conservée
4. Aller sur `/agenda`
5. ✅ Vérifier : Couleur visible dans le calendrier
---
## 📁 Fichiers modifiés
### Nouveaux fichiers :
- `app/api/groups/[groupId]/calendar/route.ts` (nouveau endpoint API)
### Fichiers modifiés :
- `components/groups/groups-table.tsx`
- Import `Palette` de lucide-react
- Ajout interface `calendarColor?` à Group
- Ajout états `colorPickerDialog`, `selectedGroupForColor`, `selectedColor`
- Fonction `fetchGroups()` modifiée pour charger couleurs
- Fonctions `handleOpenColorPicker()` et `handleSaveColor()`
- Palette de couleurs `colorPalette`
- Dialog de sélection de couleur
- Affichage pastille dans table
- `app/missions/equipe/page.tsx`
- Import `Palette` de lucide-react
- Ajout interface `calendarColor?` à Group
- Ajout états color picker
- Fonction `fetchData()` modifiée pour charger couleurs
- Fonctions `handleOpenColorPicker()` et `handleSaveColor()`
- Palette de couleurs
- Bouton palette dans actions
- Dialog de sélection de couleur
- Affichage icône colorée
---
## 🔮 Améliorations futures possibles
1. **Color picker avancé**
- Utiliser un vrai color picker (ex: `react-color`)
- Support HSL, RGB en plus de hex
2. **Thèmes prédéfinis**
- Palettes thématiques (pastel, vif, entreprise, etc.)
- Sauvegarde de palettes personnalisées
3. **Permissions granulaires**
- Seuls les admins du groupe peuvent changer la couleur
- Rôle "gestionnaire de groupe" dans Keycloak
4. **Historique des couleurs**
- Log des changements de couleur
- Possibilité de revenir en arrière
5. **Synchronisation**
- Webhook pour notifier les autres utilisateurs en temps réel
- WebSocket pour mise à jour instantanée sans refresh
---
## 📊 Impact sur les performances
- **Chargement initial** : +1 requête API par groupe (GET calendar)
- **Modification** : 1 requête PATCH, pas de refresh complet
- **Cache** : Les calendriers sont déjà en cache Redis (GET /api/calendars)
- **Optimisation** : Possibilité de batch les requêtes GET calendar en une seule
---
## 🐛 Problèmes connus
Aucun problème connu pour le moment.
---
## ✅ Checklist de déploiement
- [x] API créée et testée
- [x] Interface utilisateur implémentée
- [x] Validation des permissions
- [x] Gestion des erreurs
- [x] Pas d'erreurs de linting
- [ ] Tests unitaires (à ajouter)
- [ ] Tests E2E (à ajouter)
- [ ] Documentation utilisateur (à ajouter)
---
## 📞 Support
Pour toute question ou problème :
1. Vérifier que l'utilisateur est bien membre du groupe
2. Vérifier le format de la couleur (hex valide)
3. Vérifier les logs serveur pour les erreurs Keycloak
4. Vérifier que le calendrier du groupe existe en base
---
**Date de création** : 20 janvier 2026
**Version** : 1.0
**Auteur** : Senior Developer Assistant

View File

@ -0,0 +1,246 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/options";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { logger } from "@/lib/logger";
async function getAdminToken() {
try {
const tokenResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const data = await tokenResponse.json();
if (!tokenResponse.ok || !data.access_token) {
logger.error('Token Error', { error: data });
return null;
}
return data.access_token;
} catch (error) {
logger.error('Token Error', {
error: error instanceof Error ? error.message : String(error)
});
return null;
}
}
/**
* PATCH /api/groups/[groupId]/calendar
* Updates the color of a group's calendar
*/
export async function PATCH(
req: Request,
props: { params: Promise<{ groupId: string }> }
) {
const params = await props.params;
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { color } = await req.json();
if (!color) {
return NextResponse.json(
{ error: "La couleur est requise" },
{ status: 400 }
);
}
// Validate color format (hex color)
const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
if (!hexColorRegex.test(color)) {
return NextResponse.json(
{ error: "Format de couleur invalide. Utilisez un code hexadécimal (ex: #FF5733)" },
{ status: 400 }
);
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json(
{ error: "Erreur d'authentification" },
{ status: 500 }
);
}
// Get group details from Keycloak
const groupResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!groupResponse.ok) {
logger.error('Group not found', { groupId: params.groupId });
return NextResponse.json(
{ error: "Groupe non trouvé" },
{ status: 404 }
);
}
const group = await groupResponse.json();
// Check if user is a member of the group
const membersResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}/members`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (membersResponse.ok) {
const members = await membersResponse.json();
const isMember = members.some((member: any) => member.id === session.user.id);
if (!isMember) {
logger.warn('User not a member of group', {
userId: session.user.id,
groupId: params.groupId
});
return NextResponse.json(
{ error: "Vous devez être membre du groupe pour modifier sa couleur" },
{ status: 403 }
);
}
}
// Find the group's calendar
const calendar = await prisma.calendar.findFirst({
where: {
name: `Groupe: ${group.name}`,
},
});
if (!calendar) {
logger.error('Calendar not found for group', {
groupId: params.groupId,
groupName: group.name
});
return NextResponse.json(
{ error: "Calendrier du groupe non trouvé" },
{ status: 404 }
);
}
// Update calendar color
const updatedCalendar = await prisma.calendar.update({
where: {
id: calendar.id,
},
data: {
color: color,
},
});
logger.debug('Group calendar color updated', {
groupId: params.groupId,
calendarId: calendar.id,
newColor: color,
userId: session.user.id
});
return NextResponse.json({
success: true,
calendar: updatedCalendar,
});
} catch (error) {
logger.error('Error updating group calendar color', {
groupId: params.groupId,
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: "Erreur lors de la mise à jour de la couleur" },
{ status: 500 }
);
}
}
/**
* GET /api/groups/[groupId]/calendar
* Gets the calendar associated with a group
*/
export async function GET(
req: Request,
props: { params: Promise<{ groupId: string }> }
) {
const params = await props.params;
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json(
{ error: "Erreur d'authentification" },
{ status: 500 }
);
}
// Get group details from Keycloak
const groupResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!groupResponse.ok) {
return NextResponse.json(
{ error: "Groupe non trouvé" },
{ status: 404 }
);
}
const group = await groupResponse.json();
// Find the group's calendar
const calendar = await prisma.calendar.findFirst({
where: {
name: `Groupe: ${group.name}`,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier du groupe non trouvé" },
{ status: 404 }
);
}
return NextResponse.json(calendar);
} catch (error) {
logger.error('Error getting group calendar', {
groupId: params.groupId,
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: "Erreur lors de la récupération du calendrier" },
{ status: 500 }
);
}
}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Search, Plus, MoreHorizontal, Trash2, Edit2, Users, UserPlus, X, Check, Loader2, FolderKanban } from "lucide-react"; import { Search, Plus, MoreHorizontal, Trash2, Edit2, Users, UserPlus, X, Check, Loader2, FolderKanban, Palette } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
@ -30,6 +30,7 @@ interface Group {
name: string; name: string;
path: string; path: string;
membersCount: number; membersCount: number;
calendarColor?: string;
} }
type ActiveTab = "users" | "groups"; type ActiveTab = "users" | "groups";
@ -59,6 +60,11 @@ export default function EquipePage() {
const [userGroups, setUserGroups] = useState<Group[]>([]); const [userGroups, setUserGroups] = useState<Group[]>([]);
const [availableGroups, setAvailableGroups] = useState<Group[]>([]); const [availableGroups, setAvailableGroups] = useState<Group[]>([]);
// Color picker state
const [colorPickerDialog, setColorPickerDialog] = useState(false);
const [selectedGroupForColor, setSelectedGroupForColor] = useState<Group | null>(null);
const [selectedColor, setSelectedColor] = useState("#4f46e5");
// New user dialog state // New user dialog state
const [newUserDialogOpen, setNewUserDialogOpen] = useState(false); const [newUserDialogOpen, setNewUserDialogOpen] = useState(false);
const [newUserData, setNewUserData] = useState({ const [newUserData, setNewUserData] = useState({
@ -95,7 +101,24 @@ export default function EquipePage() {
if (groupsRes.ok) { if (groupsRes.ok) {
const groupsData = await groupsRes.json(); const groupsData = await groupsRes.json();
setGroups(Array.isArray(groupsData) ? groupsData : []);
// Fetch calendar colors for each group
const groupsWithColors = await Promise.all(
(Array.isArray(groupsData) ? groupsData : []).map(async (group: Group) => {
try {
const calendarResponse = await fetch(`/api/groups/${group.id}/calendar`);
if (calendarResponse.ok) {
const calendar = await calendarResponse.json();
return { ...group, calendarColor: calendar.color || "#4f46e5" };
}
} catch (error) {
console.warn(`Could not fetch calendar for group ${group.id}`);
}
return { ...group, calendarColor: "#4f46e5" };
})
);
setGroups(groupsWithColors);
} }
if (rolesRes.ok) { if (rolesRes.ok) {
@ -477,6 +500,61 @@ export default function EquipePage() {
} }
}; };
const handleOpenColorPicker = (group: Group) => {
setSelectedGroupForColor(group);
setSelectedColor(group.calendarColor || "#4f46e5");
setColorPickerDialog(true);
};
const handleSaveColor = async () => {
if (!selectedGroupForColor) return;
setActionLoading(selectedGroupForColor.id);
try {
const response = await fetch(`/api/groups/${selectedGroupForColor.id}/calendar`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ color: selectedColor }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Erreur lors de la mise à jour de la couleur");
}
// Update local state
setGroups(prev => prev.map(g =>
g.id === selectedGroupForColor.id
? { ...g, calendarColor: selectedColor }
: g
));
setColorPickerDialog(false);
setSelectedGroupForColor(null);
setSelectedColor("#4f46e5");
toast({
title: "Succès",
description: "La couleur du calendrier a été mise à jour",
});
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
} finally {
setActionLoading(null);
}
};
// Predefined color palette
const colorPalette = [
"#4f46e5", "#0891b2", "#0e7490", "#16a34a", "#65a30d", "#ca8a04",
"#d97706", "#dc2626", "#e11d48", "#9333ea", "#7c3aed", "#2563eb",
"#0284c7", "#059669", "#84cc16", "#eab308"
];
const createGroup = async () => { const createGroup = async () => {
if (!newGroupName.trim()) { if (!newGroupName.trim()) {
toast({ title: "Erreur", description: "Le nom du groupe est requis", variant: "destructive" }); toast({ title: "Erreur", description: "Le nom du groupe est requis", variant: "destructive" });
@ -932,8 +1010,11 @@ export default function EquipePage() {
<tr key={group.id} className={`hover:bg-gray-50 ${editMode?.id === group.id ? "bg-blue-50" : ""}`}> <tr key={group.id} className={`hover:bg-gray-50 ${editMode?.id === group.id ? "bg-blue-50" : ""}`}>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center"> <div
<Users className="h-4 w-4 text-purple-700" /> className="h-8 w-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: group.calendarColor || "#4f46e5" }}
>
<Users className="h-4 w-4 text-white" />
</div> </div>
<span className="text-sm font-medium text-gray-900">{group.name}</span> <span className="text-sm font-medium text-gray-900">{group.name}</span>
</div> </div>
@ -952,15 +1033,27 @@ export default function EquipePage() {
onClick={() => handleEditGroup(group)} onClick={() => handleEditGroup(group)}
disabled={actionLoading === group.id} disabled={actionLoading === group.id}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
title="Modifier"
> >
<Edit2 className="h-4 w-4 text-gray-500" /> <Edit2 className="h-4 w-4 text-gray-500" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenColorPicker(group)}
disabled={actionLoading === group.id}
className="h-8 w-8 p-0"
title="Couleur du calendrier"
>
<Palette className="h-4 w-4 text-gray-500" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleManageMembers(group)} onClick={() => handleManageMembers(group)}
disabled={actionLoading === group.id} disabled={actionLoading === group.id}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
title="Gérer les membres"
> >
{actionLoading === group.id ? ( {actionLoading === group.id ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@ -974,6 +1067,7 @@ export default function EquipePage() {
onClick={() => deleteGroup(group.id)} onClick={() => deleteGroup(group.id)}
disabled={actionLoading === group.id} disabled={actionLoading === group.id}
className="h-8 w-8 p-0 hover:text-red-600" className="h-8 w-8 p-0 hover:text-red-600"
title="Supprimer"
> >
<Trash2 className="h-4 w-4 text-gray-500" /> <Trash2 className="h-4 w-4 text-gray-500" />
</Button> </Button>
@ -1272,6 +1366,81 @@ export default function EquipePage() {
</div> </div>
)} )}
</div> </div>
{/* Color Picker Dialog */}
{colorPickerDialog && (
<Dialog open={colorPickerDialog} onOpenChange={setColorPickerDialog}>
<DialogContent className="sm:max-w-[400px] bg-white text-black border border-gray-300">
<DialogHeader>
<DialogTitle className="text-gray-900">
Changer la couleur du calendrier - {selectedGroupForColor?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-gray-900 mb-3 block">Couleur sélectionnée</Label>
<div className="flex items-center gap-3 mb-4">
<div
className="w-16 h-16 rounded-lg border-2 border-gray-300 shadow-sm"
style={{ backgroundColor: selectedColor }}
/>
<div className="flex-1">
<Input
type="text"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="bg-white text-gray-900 border-gray-300 font-mono"
placeholder="#4f46e5"
/>
</div>
</div>
</div>
<div>
<Label className="text-gray-900 mb-3 block">Palette de couleurs</Label>
<div className="grid grid-cols-8 gap-2">
{colorPalette.map((color) => (
<button
key={color}
type="button"
onClick={() => setSelectedColor(color)}
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 ${
selectedColor === color ? 'border-gray-900 shadow-md' : 'border-gray-300'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => {
setColorPickerDialog(false);
setSelectedGroupForColor(null);
setSelectedColor("#4f46e5");
}}
className="border-gray-300 text-gray-700 hover:bg-gray-50 bg-white"
>
Annuler
</Button>
<Button
onClick={handleSaveColor}
disabled={actionLoading === selectedGroupForColor?.id}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{actionLoading === selectedGroupForColor?.id ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Enregistrer
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div> </div>
); );
} }

View File

@ -11,7 +11,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Plus, MoreHorizontal, Trash, Edit, Users } from "lucide-react"; import { Plus, MoreHorizontal, Trash, Edit, Users, Palette } from "lucide-react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -35,6 +35,7 @@ interface Group {
name: string; name: string;
path: string; path: string;
membersCount: number; membersCount: number;
calendarColor?: string;
} }
interface User { interface User {
@ -100,6 +101,9 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
const [manageMembersDialog, setManageMembersDialog] = useState(false); const [manageMembersDialog, setManageMembersDialog] = useState(false);
const [groupMembers, setGroupMembers] = useState<User[]>([]); const [groupMembers, setGroupMembers] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]); const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [colorPickerDialog, setColorPickerDialog] = useState(false);
const [selectedGroupForColor, setSelectedGroupForColor] = useState<Group | null>(null);
const [selectedColor, setSelectedColor] = useState("#4f46e5");
useEffect(() => { useEffect(() => {
fetchGroups(); fetchGroups();
@ -118,18 +122,34 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
const groupsWithCounts = await Promise.all( const groupsWithCounts = await Promise.all(
(Array.isArray(data) ? data : []).map(async (group) => { (Array.isArray(data) ? data : []).map(async (group) => {
try { try {
// Fetch members count
const membersResponse = await fetch(`/api/groups/${group.id}/members`); const membersResponse = await fetch(`/api/groups/${group.id}/members`);
let membersCount = 0;
if (membersResponse.ok) { if (membersResponse.ok) {
const members = await membersResponse.json(); const members = await membersResponse.json();
return { membersCount = Array.isArray(members) ? members.length : 0;
...group,
membersCount: Array.isArray(members) ? members.length : 0
};
} }
return group;
// Fetch calendar color
let calendarColor = "#4f46e5"; // Default color
try {
const calendarResponse = await fetch(`/api/groups/${group.id}/calendar`);
if (calendarResponse.ok) {
const calendar = await calendarResponse.json();
calendarColor = calendar.color || calendarColor;
}
} catch (calendarError) {
console.warn(`Could not fetch calendar for group ${group.id}:`, calendarError);
}
return {
...group,
membersCount,
calendarColor,
};
} catch (error) { } catch (error) {
console.error(`Error fetching members for group ${group.id}:`, error); console.error(`Error fetching data for group ${group.id}:`, error);
return group; return { ...group, membersCount: 0, calendarColor: "#4f46e5" };
} }
}) })
); );
@ -391,6 +411,75 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
setAvailableUsers([]); setAvailableUsers([]);
}, []); }, []);
const resetColorPickerForm = useCallback(() => {
setSelectedGroupForColor(null);
setSelectedColor("#4f46e5");
}, []);
const handleOpenColorPicker = (group: Group) => {
setSelectedGroupForColor(group);
setSelectedColor(group.calendarColor || "#4f46e5");
setColorPickerDialog(true);
};
const handleSaveColor = async () => {
if (!selectedGroupForColor) return;
try {
const response = await fetch(`/api/groups/${selectedGroupForColor.id}/calendar`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ color: selectedColor }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Erreur lors de la mise à jour de la couleur");
}
// Update local state
setGroups(prev => prev.map(g =>
g.id === selectedGroupForColor.id
? { ...g, calendarColor: selectedColor }
: g
));
setColorPickerDialog(false);
toast({
title: "Succès",
description: "La couleur du calendrier a été mise à jour",
});
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
}
};
// Predefined color palette
const colorPalette = [
"#4f46e5", // Indigo
"#0891b2", // Cyan
"#0e7490", // Teal
"#16a34a", // Green
"#65a30d", // Lime
"#ca8a04", // Amber
"#d97706", // Orange
"#dc2626", // Red
"#e11d48", // Rose
"#9333ea", // Purple
"#7c3aed", // Violet
"#2563eb", // Blue
"#0284c7", // Sky
"#059669", // Emerald
"#84cc16", // Lime
"#eab308", // Yellow
];
if (loading) return <div className="text-center p-4">Loading...</div>; if (loading) return <div className="text-center p-4">Loading...</div>;
return ( return (
@ -455,7 +544,15 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
) )
.map((group) => ( .map((group) => (
<TableRow key={group.id} className="hover:bg-gray-50 border-t border-gray-200"> <TableRow key={group.id} className="hover:bg-gray-50 border-t border-gray-200">
<TableCell className="text-gray-900 font-medium">{group.name}</TableCell> <TableCell className="text-gray-900 font-medium">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: group.calendarColor || "#4f46e5" }}
/>
{group.name}
</div>
</TableCell>
<TableCell className="text-gray-900">{group.path}</TableCell> <TableCell className="text-gray-900">{group.path}</TableCell>
<TableCell className="text-gray-900">{group.membersCount}</TableCell> <TableCell className="text-gray-900">{group.membersCount}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@ -478,6 +575,13 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Modifier Modifier
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleOpenColorPicker(group)}
className="text-gray-900 hover:bg-gray-100"
>
<Palette className="mr-2 h-4 w-4" />
Couleur du calendrier
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleManageMembers(group.id)} onClick={() => handleManageMembers(group.id)}
className="text-gray-900 hover:bg-gray-100" className="text-gray-900 hover:bg-gray-100"
@ -530,6 +634,76 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
</div> </div>
</DialogWrapper> </DialogWrapper>
<DialogWrapper
open={colorPickerDialog}
onOpenChange={setColorPickerDialog}
onAfterClose={resetColorPickerForm}
className="sm:max-w-[400px] bg-white text-black border border-gray-300"
>
<DialogHeader>
<DialogTitle className="text-gray-900">
Changer la couleur du calendrier - {selectedGroupForColor?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-gray-900 mb-3 block">Couleur sélectionnée</Label>
<div className="flex items-center gap-3 mb-4">
<div
className="w-16 h-16 rounded-lg border-2 border-gray-300 shadow-sm"
style={{ backgroundColor: selectedColor }}
/>
<div className="flex-1">
<Input
type="text"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="bg-white text-gray-900 border-gray-300 font-mono"
placeholder="#4f46e5"
/>
</div>
</div>
</div>
<div>
<Label className="text-gray-900 mb-3 block">Palette de couleurs</Label>
<div className="grid grid-cols-8 gap-2">
{colorPalette.map((color) => (
<button
key={color}
type="button"
onClick={() => setSelectedColor(color)}
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 ${
selectedColor === color ? 'border-gray-900 shadow-md' : 'border-gray-300'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => {
setColorPickerDialog(false);
resetColorPickerForm();
}}
className="border-gray-300 text-gray-700 hover:bg-gray-50 bg-white"
>
Annuler
</Button>
<Button
onClick={handleSaveColor}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
Enregistrer
</Button>
</div>
</div>
</DialogWrapper>
<DialogWrapper <DialogWrapper
open={manageMembersDialog} open={manageMembersDialog}
onOpenChange={setManageMembersDialog} onOpenChange={setManageMembersDialog}