mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-21 22:28:18 +02:00
Merge pull request #50 from R0m1k3/claude/integrate-google-calendar-api-011CV44Nie9HB7U35ArSUnNa
Claude/integrate google calendar api 011 cv44 nie9 hb7 u35 ar s un na
This commit is contained in:
3 files changed
+395
-58
No files matched your search
+112
-2
@@ -6,8 +6,8 @@ const { getAll, getOne, runQuery } = require('../config/database');
|
|||||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||||
const logger = require('../config/logger');
|
const logger = require('../config/logger');
|
||||||
|
|
||||||
// Scopes Google Calendar requis
|
// Scopes Google Calendar requis (lecture ET écriture)
|
||||||
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
|
const SCOPES = ['https://www.googleapis.com/auth/calendar'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Types d'authentification supportés
|
* Types d'authentification supportés
|
||||||
@@ -496,6 +496,116 @@ router.post('/disconnect', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/calendar/events
|
||||||
|
* Créer un événement dans Google Calendar
|
||||||
|
*/
|
||||||
|
router.post('/events', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
startDateTime,
|
||||||
|
endDateTime,
|
||||||
|
location,
|
||||||
|
attendees,
|
||||||
|
reminders,
|
||||||
|
recurrence,
|
||||||
|
visibility,
|
||||||
|
colorId
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title || !startDateTime || !endDateTime) {
|
||||||
|
return res.status(400).json({ error: 'Titre, date de début et date de fin requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = await getCalendarClient(req.user.id);
|
||||||
|
|
||||||
|
// Préparer l'événement
|
||||||
|
const event = {
|
||||||
|
summary: title,
|
||||||
|
location: location || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
start: {
|
||||||
|
dateTime: startDateTime,
|
||||||
|
timeZone: 'Europe/Paris',
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: endDateTime,
|
||||||
|
timeZone: 'Europe/Paris',
|
||||||
|
},
|
||||||
|
attendees: attendees && attendees.length > 0 ?
|
||||||
|
attendees.map(email => ({ email })) : undefined,
|
||||||
|
reminders: reminders ? {
|
||||||
|
useDefault: false,
|
||||||
|
overrides: reminders
|
||||||
|
} : undefined,
|
||||||
|
recurrence: recurrence || undefined,
|
||||||
|
visibility: visibility || 'default',
|
||||||
|
colorId: colorId || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer le Calendar ID depuis les settings
|
||||||
|
const calendarIdSetting = await getOne("SELECT value FROM settings WHERE key = 'google_calendar_id'");
|
||||||
|
const calendarId = calendarIdSetting?.value || 'primary';
|
||||||
|
|
||||||
|
// Créer l'événement dans Google Calendar
|
||||||
|
const response = await calendar.events.insert({
|
||||||
|
calendarId: calendarId,
|
||||||
|
resource: event,
|
||||||
|
sendUpdates: attendees && attendees.length > 0 ? 'all' : 'none'
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Événement créé: ${response.data.id} pour l'utilisateur ${req.user.username}`);
|
||||||
|
|
||||||
|
// Synchroniser après création pour mettre à jour la base locale
|
||||||
|
try {
|
||||||
|
const events = await calendar.events.list({
|
||||||
|
calendarId: calendarId,
|
||||||
|
timeMin: new Date().toISOString(),
|
||||||
|
maxResults: 10,
|
||||||
|
singleEvents: true,
|
||||||
|
orderBy: 'startTime'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (events.data.items && events.data.items.length > 0) {
|
||||||
|
for (const item of events.data.items) {
|
||||||
|
await runQuery(`
|
||||||
|
INSERT OR REPLACE INTO calendar_events
|
||||||
|
(google_event_id, user_id, title, description, start_time, end_time, location, html_link, synced_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
`, [
|
||||||
|
item.id,
|
||||||
|
req.user.id,
|
||||||
|
item.summary || 'Sans titre',
|
||||||
|
item.description || null,
|
||||||
|
item.start.dateTime || item.start.date,
|
||||||
|
item.end.dateTime || item.end.date,
|
||||||
|
item.location || null,
|
||||||
|
item.htmlLink || null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (syncError) {
|
||||||
|
logger.warn('Erreur lors de la synchronisation post-création:', syncError);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Événement créé avec succès',
|
||||||
|
eventId: response.data.id,
|
||||||
|
htmlLink: response.data.htmlLink
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la création de l\'événement:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Erreur lors de la création de l\'événement',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/calendar/events/:id
|
* DELETE /api/calendar/events/:id
|
||||||
* Supprimer un événement local
|
* Supprimer un événement local
|
||||||
|
|||||||
+264
-56
@@ -56,6 +56,7 @@ const Index = () => {
|
|||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
const [currentDateTime, setCurrentDateTime] = useState(new Date());
|
const [currentDateTime, setCurrentDateTime] = useState(new Date());
|
||||||
const [upcomingAlert, setUpcomingAlert] = useState<CalendarEvent | null>(null);
|
const [upcomingAlert, setUpcomingAlert] = useState<CalendarEvent | null>(null);
|
||||||
|
const [rssDisplayOffset, setRssDisplayOffset] = useState(0);
|
||||||
const [calendarAuthStatus, setCalendarAuthStatus] = useState<{
|
const [calendarAuthStatus, setCalendarAuthStatus] = useState<{
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isExpired: boolean;
|
isExpired: boolean;
|
||||||
@@ -78,6 +79,7 @@ const Index = () => {
|
|||||||
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
|
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
|
||||||
const [addTagModal, setAddTagModal] = useState(false);
|
const [addTagModal, setAddTagModal] = useState(false);
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
const [addEventModal, setAddEventModal] = useState(false);
|
||||||
|
|
||||||
// Debounce timer for auto-save
|
// Debounce timer for auto-save
|
||||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -109,6 +111,19 @@ const Index = () => {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [calendarEvents]);
|
}, [calendarEvents]);
|
||||||
|
|
||||||
|
// RSS articles rotation every 20 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (rssArticles.length > 3) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setRssDisplayOffset(prev => {
|
||||||
|
const maxOffset = Math.max(0, rssArticles.length - 3);
|
||||||
|
return (prev + 3) % (maxOffset + 3);
|
||||||
|
});
|
||||||
|
}, 20000); // Rotate every 20 seconds
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [rssArticles.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userFromAuth = AuthService.getUser();
|
const userFromAuth = AuthService.getUser();
|
||||||
if (userFromAuth) {
|
if (userFromAuth) {
|
||||||
@@ -698,17 +713,22 @@ const Index = () => {
|
|||||||
<CalendarIcon className="h-6 w-6" />
|
<CalendarIcon className="h-6 w-6" />
|
||||||
Rendez-vous
|
Rendez-vous
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button size="sm" variant="outline" onClick={async () => {
|
<div className="flex gap-1.5">
|
||||||
try {
|
<Button size="sm" variant="outline" onClick={() => setAddEventModal(true)}>
|
||||||
const result = await CalendarService.sync();
|
<Plus className="h-4 w-4" />
|
||||||
showSuccess(`${result.syncedCount} événements synchronisés`);
|
</Button>
|
||||||
await loadCalendarEvents();
|
<Button size="sm" variant="outline" onClick={async () => {
|
||||||
} catch (error) {
|
try {
|
||||||
showError("Erreur lors de la synchronisation");
|
const result = await CalendarService.sync();
|
||||||
}
|
showSuccess(`${result.syncedCount} événements synchronisés`);
|
||||||
}} className="gap-1">
|
await loadCalendarEvents();
|
||||||
<RefreshCw className="h-4 w-4" />
|
} catch (error) {
|
||||||
</Button>
|
showError("Erreur lors de la synchronisation");
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -728,7 +748,12 @@ const Index = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-medium line-clamp-2 mb-1 text-sm">{event.title}</h4>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{isToday && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-blue-500 flex-shrink-0 animate-pulse" />
|
||||||
|
)}
|
||||||
|
<h4 className="font-medium line-clamp-2 text-sm">{event.title}</h4>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isToday ? "Aujourd'hui" : startDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} à {startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
{isToday ? "Aujourd'hui" : startDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} à {startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</p>
|
</p>
|
||||||
@@ -1171,79 +1196,78 @@ const Index = () => {
|
|||||||
<div className="space-y-6 h-fit sticky top-24">
|
<div className="space-y-6 h-fit sticky top-24">
|
||||||
{/* Todos Box */}
|
{/* Todos Box */}
|
||||||
<Card className="shadow-lg">
|
<Card className="shadow-lg">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
<CheckSquare className="h-6 w-6" />
|
<CheckSquare className="h-5 w-5" />
|
||||||
Tâches
|
Tâches
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button size="lg" variant="outline" onClick={() => setAddTodoModal(true)} className="gap-2">
|
<Button size="sm" variant="outline" onClick={() => setAddTodoModal(true)}>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-4 w-4" />
|
||||||
Ajouter
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="active" className="w-full">
|
<Tabs defaultValue="active" className="w-full">
|
||||||
<TabsList className="grid grid-cols-2 w-full">
|
<TabsList className="grid grid-cols-2 w-full mb-3">
|
||||||
<TabsTrigger value="active">Actives ({todos.filter(t => !t.completed).length})</TabsTrigger>
|
<TabsTrigger value="active" className="text-xs">Actives ({todos.filter(t => !t.completed).length})</TabsTrigger>
|
||||||
<TabsTrigger value="completed">Complétées ({todos.filter(t => t.completed).length})</TabsTrigger>
|
<TabsTrigger value="completed" className="text-xs">Complétées ({todos.filter(t => t.completed).length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="active" className="mt-4">
|
<TabsContent value="active" className="mt-0">
|
||||||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
<div className="space-y-1.5 max-h-[350px] overflow-y-auto">
|
||||||
{todos.filter(t => !t.completed).length > 0 ? (
|
{todos.filter(t => !t.completed).length > 0 ? (
|
||||||
todos.filter(t => !t.completed).map(todo => (
|
todos.filter(t => !t.completed).map(todo => (
|
||||||
<div key={todo.id} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-accent/50 transition-colors group">
|
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={false}
|
checked={false}
|
||||||
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
||||||
className="h-5 w-5"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<span className="text-base flex-1">
|
<span className="text-sm flex-1 leading-snug">
|
||||||
{todo.text}
|
{todo.text}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-muted-foreground py-8">Aucune tâche active</p>
|
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche active</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="completed" className="mt-4">
|
<TabsContent value="completed" className="mt-0">
|
||||||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
<div className="space-y-1.5 max-h-[350px] overflow-y-auto">
|
||||||
{todos.filter(t => t.completed).length > 0 ? (
|
{todos.filter(t => t.completed).length > 0 ? (
|
||||||
todos.filter(t => t.completed).map(todo => (
|
todos.filter(t => t.completed).map(todo => (
|
||||||
<div key={todo.id} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-accent/50 transition-colors group">
|
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={true}
|
checked={true}
|
||||||
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
||||||
className="h-5 w-5"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<span className="text-base flex-1 line-through text-muted-foreground">
|
<span className="text-sm flex-1 line-through text-muted-foreground leading-snug">
|
||||||
{todo.text}
|
{todo.text}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-muted-foreground py-8">Aucune tâche complétée</p>
|
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche complétée</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1253,59 +1277,71 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* RSS Box */}
|
{/* RSS Box */}
|
||||||
<Card className="shadow-lg">
|
<Card className="shadow-lg">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
<CardTitle className="text-xl flex items-center gap-2 flex-shrink-0">
|
||||||
<Rss className="h-6 w-6" />
|
<Rss className="h-5 w-5" />
|
||||||
Flux RSS
|
Flux RSS
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1.5 flex-shrink-0">
|
||||||
<Button size="lg" variant="outline" onClick={handleRefreshRss} className="gap-2">
|
<Button size="sm" variant="outline" onClick={handleRefreshRss}>
|
||||||
<RefreshCw className="h-5 w-5" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
Actualiser
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" variant="outline" onClick={() => setAddRssFeedModal(true)} className="gap-2">
|
<Button size="sm" variant="outline" onClick={() => setAddRssFeedModal(true)}>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-4 w-4" />
|
||||||
Ajouter
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3 max-h-[480px] overflow-y-auto">
|
<div className="space-y-2">
|
||||||
{rssArticles.length > 0 ? (
|
{rssArticles.length > 0 ? (
|
||||||
rssArticles.map(article => (
|
rssArticles.slice(rssDisplayOffset, rssDisplayOffset + 3).map(article => (
|
||||||
<div
|
<div
|
||||||
key={article.id}
|
key={article.id}
|
||||||
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer"
|
className="p-2.5 border rounded hover:bg-accent/50 transition-colors cursor-pointer"
|
||||||
onClick={() => article.link && window.open(article.link, '_blank')}
|
onClick={() => article.link && window.open(article.link, '_blank')}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-medium line-clamp-2 mb-1">{article.title}</h4>
|
<h4 className="font-medium text-sm line-clamp-2 mb-1">{article.title}</h4>
|
||||||
{article.description && (
|
{article.description && (
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
{article.description.replace(/<[^>]*>/g, '')}
|
{article.description.replace(/<[^>]*>/g, '')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-muted-foreground py-8">
|
<p className="text-center text-muted-foreground text-sm py-6">
|
||||||
Aucun article
|
Aucun article
|
||||||
<br />
|
<br />
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="mt-2"
|
className="mt-2 text-xs"
|
||||||
onClick={() => setAddRssFeedModal(true)}
|
onClick={() => setAddRssFeedModal(true)}
|
||||||
>
|
>
|
||||||
Ajouter un flux RSS
|
Ajouter un flux RSS
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{rssArticles.length > 3 && (
|
||||||
|
<div className="flex justify-center pt-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Array.from({ length: Math.ceil(rssArticles.length / 3) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full transition-colors ${
|
||||||
|
i === Math.floor(rssDisplayOffset / 3) ? 'bg-primary' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1903,6 +1939,178 @@ const Index = () => {
|
|||||||
confirmText="Ajouter"
|
confirmText="Ajouter"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Event Modal */}
|
||||||
|
<Dialog open={addEventModal} onOpenChange={setAddEventModal}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ajouter un rendez-vous</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Créer un événement dans votre Google Calendar
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventData = {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
description: formData.get('description') as string || undefined,
|
||||||
|
startDateTime: new Date(formData.get('startDateTime') as string).toISOString(),
|
||||||
|
endDateTime: new Date(formData.get('endDateTime') as string).toISOString(),
|
||||||
|
location: formData.get('location') as string || undefined,
|
||||||
|
attendees: (formData.get('attendees') as string || '').split(',').map(e => e.trim()).filter(e => e),
|
||||||
|
reminders: formData.get('reminderMinutes') ? [
|
||||||
|
{ method: 'popup', minutes: parseInt(formData.get('reminderMinutes') as string) }
|
||||||
|
] : undefined,
|
||||||
|
recurrence: formData.get('recurrence') ? [formData.get('recurrence') as string] : undefined,
|
||||||
|
visibility: formData.get('visibility') as string || 'default',
|
||||||
|
colorId: formData.get('colorId') as string || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
await CalendarService.createEvent(eventData);
|
||||||
|
showSuccess('Événement créé avec succès');
|
||||||
|
setAddEventModal(false);
|
||||||
|
await loadCalendarEvents();
|
||||||
|
} catch (error: any) {
|
||||||
|
showError(error.response?.data?.error || 'Erreur lors de la création de l\'événement');
|
||||||
|
}
|
||||||
|
}} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Titre *</Label>
|
||||||
|
<Input id="title" name="title" required placeholder="Réunion d'équipe" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
className="w-full min-h-[80px] px-3 py-2 border border-input rounded-md bg-background"
|
||||||
|
placeholder="Détails de l'événement..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startDateTime">Début *</Label>
|
||||||
|
<Input
|
||||||
|
id="startDateTime"
|
||||||
|
name="startDateTime"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
defaultValue={new Date(Date.now() + 60*60*1000).toISOString().slice(0, 16)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endDateTime">Fin *</Label>
|
||||||
|
<Input
|
||||||
|
id="endDateTime"
|
||||||
|
name="endDateTime"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
defaultValue={new Date(Date.now() + 2*60*60*1000).toISOString().slice(0, 16)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="location">Lieu</Label>
|
||||||
|
<Input id="location" name="location" placeholder="Salle de réunion A" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="attendees">Participants (emails séparés par des virgules)</Label>
|
||||||
|
<Input
|
||||||
|
id="attendees"
|
||||||
|
name="attendees"
|
||||||
|
type="text"
|
||||||
|
placeholder="email1@example.com, email2@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminderMinutes">Rappel (minutes avant)</Label>
|
||||||
|
<Select name="reminderMinutes" defaultValue="30">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner un rappel" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">Aucun</SelectItem>
|
||||||
|
<SelectItem value="10">10 minutes</SelectItem>
|
||||||
|
<SelectItem value="30">30 minutes</SelectItem>
|
||||||
|
<SelectItem value="60">1 heure</SelectItem>
|
||||||
|
<SelectItem value="1440">1 jour</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recurrence">Récurrence</Label>
|
||||||
|
<Select name="recurrence" defaultValue="">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Pas de récurrence" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Pas de récurrence</SelectItem>
|
||||||
|
<SelectItem value="RRULE:FREQ=DAILY">Tous les jours</SelectItem>
|
||||||
|
<SelectItem value="RRULE:FREQ=WEEKLY">Toutes les semaines</SelectItem>
|
||||||
|
<SelectItem value="RRULE:FREQ=MONTHLY">Tous les mois</SelectItem>
|
||||||
|
<SelectItem value="RRULE:FREQ=YEARLY">Tous les ans</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="visibility">Visibilité</Label>
|
||||||
|
<Select name="visibility" defaultValue="default">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner la visibilité" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Par défaut</SelectItem>
|
||||||
|
<SelectItem value="public">Public</SelectItem>
|
||||||
|
<SelectItem value="private">Privé</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="colorId">Couleur</Label>
|
||||||
|
<Select name="colorId" defaultValue="">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Couleur par défaut" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Par défaut</SelectItem>
|
||||||
|
<SelectItem value="1">Lavande</SelectItem>
|
||||||
|
<SelectItem value="2">Sauge</SelectItem>
|
||||||
|
<SelectItem value="3">Raisin</SelectItem>
|
||||||
|
<SelectItem value="4">Flamant rose</SelectItem>
|
||||||
|
<SelectItem value="5">Banane</SelectItem>
|
||||||
|
<SelectItem value="6">Mandarine</SelectItem>
|
||||||
|
<SelectItem value="7">Paon</SelectItem>
|
||||||
|
<SelectItem value="8">Graphite</SelectItem>
|
||||||
|
<SelectItem value="9">Myrtille</SelectItem>
|
||||||
|
<SelectItem value="10">Basilic</SelectItem>
|
||||||
|
<SelectItem value="11">Tomate</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setAddEventModal(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
Créer l'événement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<div className="fixed bottom-4 right-4">
|
<div className="fixed bottom-4 right-4">
|
||||||
<MadeWithDyad />
|
<MadeWithDyad />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,6 +91,25 @@ class CalendarService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Créer un événement dans Google Calendar
|
||||||
|
*/
|
||||||
|
async createEvent(eventData: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
startDateTime: string;
|
||||||
|
endDateTime: string;
|
||||||
|
location?: string;
|
||||||
|
attendees?: string[];
|
||||||
|
reminders?: Array<{ method: string; minutes: number }>;
|
||||||
|
recurrence?: string[];
|
||||||
|
visibility?: string;
|
||||||
|
colorId?: string;
|
||||||
|
}): Promise<{ eventId: string; htmlLink: string }> {
|
||||||
|
const response = await axios.post(`${this.baseURL}/events`, eventData, this.getAuthHeader());
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CalendarService();
|
export default new CalendarService();
|
||||||
Reference in new issue
Block a user