Add ICS export functionality to Planning

This commit is contained in:
Arthur Belleville 2025-07-27 14:28:21 +02:00
parent a08375ad42
commit 394fc3fd22
No known key found for this signature in database
2 changed files with 128 additions and 0 deletions

View file

@ -9,6 +9,7 @@ import {
SelectListItem,
} from "@ui/ui-library/select";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { generateICSFromEvents, downloadICSFile } from "@ui/utils/helpers";
type ViewType = "month" | "week" | "day";
@ -31,6 +32,27 @@ export const PlanningPage = () => {
const deleteEvent = useDeleteEvent();
const handleExportICS = () => {
if (!tabloEvents || tabloEvents.length === 0) {
return;
}
const calendarName =
selectedTabloId === "all"
? "Planning - Tous les tablos"
: tablos?.find((t) => t.id === selectedTabloId)?.name || "Planning";
const icsContent = generateICSFromEvents(tabloEvents, calendarName);
const filename =
selectedTabloId === "all"
? "planning-tous-tablos.ics"
: `planning-${
tablos?.find((t) => t.id === selectedTabloId)?.name || "tablo"
}.ics`;
downloadICSFile(icsContent, filename);
};
const navigateToCreateEvent = (date: Date, tablo_id: string) => {
if (tablo_id === "all") {
navigate({
@ -732,6 +754,27 @@ export const PlanningPage = () => {
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleExportICS}
disabled={!tabloEvents || tabloEvents.length === 0}
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Exporter en format ICS"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
<span>Exporter</span>
</button>
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{(["month", "week", "day"] as ViewType[]).map((view) => (
<button

View file

@ -1,4 +1,5 @@
import { Database } from "@ui/types/database.types";
import { EventAndTablo } from "@ui/types/events.types";
import jsPDF from "jspdf";
export const calculateTax = (amount: number, taxRate: number) => {
@ -79,3 +80,87 @@ export const exportDevisToPdf = (devis: Devis) => {
export const isStaging = import.meta.env.MODE === "staging";
export const isProd = import.meta.env.MODE === "production";
// ICS Export functionality
export const generateICSFromEvents = (
events: EventAndTablo[],
calendarName: string = "Planning"
) => {
const formatDate = (date: string, time: string) => {
// Combine date (YYYY-MM-DD) and time (HH:MM:SS) into ISO format then convert to UTC
const dateTime = new Date(`${date}T${time}`);
return dateTime.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
};
const escapeICSText = (text: string) => {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n")
.replace(/\r/g, "");
};
const generateUID = (eventId: string) => {
return `${eventId}@xtablo.com`;
};
let icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//XTablo//Planning Export//EN",
`X-WR-CALNAME:${escapeICSText(calendarName)}`,
"X-WR-TIMEZONE:Europe/Paris",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
].join("\r\n");
events.forEach((event) => {
if (!event.start_date || !event.start_time || !event.title) return;
console.log("event", event);
const startDateTime = formatDate(event.start_date, event.start_time);
const endDateTime = event.end_time
? formatDate(event.start_date, event.end_time)
: formatDate(event.start_date, event.start_time); // Default to start time if no end time
const eventLines = [
"",
"BEGIN:VEVENT",
`UID:${generateUID(event.event_id)}`,
`DTSTART:${startDateTime}`,
`DTEND:${endDateTime}`,
`SUMMARY:${escapeICSText(event.title)}`,
`DESCRIPTION:${escapeICSText(event.description || "")}`,
event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "",
`CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
`LAST-MODIFIED:${
new Date().toISOString().replace(/[-:]/g, "").split(".")[0]
}Z`,
"STATUS:CONFIRMED",
"TRANSP:OPAQUE",
"END:VEVENT",
].filter((line) => line !== ""); // Remove empty lines
icsContent += "\r\n" + eventLines.join("\r\n");
});
icsContent += "\r\n" + "END:VCALENDAR";
return icsContent;
};
export const downloadICSFile = (
icsContent: string,
filename: string = "planning.ics"
) => {
console.log("icsContent", icsContent);
const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
};