agenda finition
This commit is contained in:
parent
4dd5ef3ac1
commit
de4cceaf3d
265
FEATURE_GROUP_CALENDAR_COLOR.md
Normal file
265
FEATURE_GROUP_CALENDAR_COLOR.md
Normal 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
|
||||
246
app/api/groups/[groupId]/calendar/route.ts
Normal file
246
app/api/groups/[groupId]/calendar/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
@ -30,6 +30,7 @@ interface Group {
|
||||
name: string;
|
||||
path: string;
|
||||
membersCount: number;
|
||||
calendarColor?: string;
|
||||
}
|
||||
|
||||
type ActiveTab = "users" | "groups";
|
||||
@ -59,6 +60,11 @@ export default function EquipePage() {
|
||||
const [userGroups, setUserGroups] = 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
|
||||
const [newUserDialogOpen, setNewUserDialogOpen] = useState(false);
|
||||
const [newUserData, setNewUserData] = useState({
|
||||
@ -95,7 +101,24 @@ export default function EquipePage() {
|
||||
|
||||
if (groupsRes.ok) {
|
||||
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) {
|
||||
@ -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 () => {
|
||||
if (!newGroupName.trim()) {
|
||||
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" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<Users className="h-4 w-4 text-purple-700" />
|
||||
<div
|
||||
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>
|
||||
<span className="text-sm font-medium text-gray-900">{group.name}</span>
|
||||
</div>
|
||||
@ -952,15 +1033,27 @@ export default function EquipePage() {
|
||||
onClick={() => handleEditGroup(group)}
|
||||
disabled={actionLoading === group.id}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 className="h-4 w-4 text-gray-500" />
|
||||
</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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleManageMembers(group)}
|
||||
disabled={actionLoading === group.id}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Gérer les membres"
|
||||
>
|
||||
{actionLoading === group.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@ -974,6 +1067,7 @@ export default function EquipePage() {
|
||||
onClick={() => deleteGroup(group.id)}
|
||||
disabled={actionLoading === group.id}
|
||||
className="h-8 w-8 p-0 hover:text-red-600"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
@ -1272,6 +1366,81 @@ export default function EquipePage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -35,6 +35,7 @@ interface Group {
|
||||
name: string;
|
||||
path: string;
|
||||
membersCount: number;
|
||||
calendarColor?: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@ -100,6 +101,9 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
|
||||
const [manageMembersDialog, setManageMembersDialog] = useState(false);
|
||||
const [groupMembers, setGroupMembers] = 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(() => {
|
||||
fetchGroups();
|
||||
@ -118,18 +122,34 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
|
||||
const groupsWithCounts = await Promise.all(
|
||||
(Array.isArray(data) ? data : []).map(async (group) => {
|
||||
try {
|
||||
// Fetch members count
|
||||
const membersResponse = await fetch(`/api/groups/${group.id}/members`);
|
||||
let membersCount = 0;
|
||||
if (membersResponse.ok) {
|
||||
const members = await membersResponse.json();
|
||||
return {
|
||||
...group,
|
||||
membersCount: Array.isArray(members) ? members.length : 0
|
||||
};
|
||||
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) {
|
||||
console.error(`Error fetching members for group ${group.id}:`, error);
|
||||
return group;
|
||||
console.error(`Error fetching data for group ${group.id}:`, error);
|
||||
return { ...group, membersCount: 0, calendarColor: "#4f46e5" };
|
||||
}
|
||||
})
|
||||
);
|
||||
@ -391,6 +411,75 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
|
||||
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>;
|
||||
|
||||
return (
|
||||
@ -455,7 +544,15 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
|
||||
)
|
||||
.map((group) => (
|
||||
<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.membersCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@ -478,6 +575,13 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Modifier
|
||||
</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
|
||||
onClick={() => handleManageMembers(group.id)}
|
||||
className="text-gray-900 hover:bg-gray-100"
|
||||
@ -530,6 +634,76 @@ export function GroupsTable({ userRole = [] }: GroupsTableProps) {
|
||||
</div>
|
||||
</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
|
||||
open={manageMembersDialog}
|
||||
onOpenChange={setManageMembersDialog}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user