348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
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) => {
|
|
return (amount * taxRate) / 100;
|
|
};
|
|
|
|
export const calculateTotal = (amount: number, tax: number) => {
|
|
return amount + tax;
|
|
};
|
|
|
|
export const statusToText: Record<Devis["status"], string> = {
|
|
draft: "Brouillon",
|
|
sent: "Envoyé",
|
|
accepted: "Accepté",
|
|
rejected: "Rejeté",
|
|
expired: "Expiré",
|
|
};
|
|
|
|
type Devis = Database["public"]["Tables"]["devis"]["Row"];
|
|
|
|
export const exportDevisToPdf = (devis: Devis) => {
|
|
const doc = new jsPDF();
|
|
|
|
// --- Basic PDF Content ---
|
|
doc.setFontSize(22);
|
|
doc.text(`Devis ${devis.number}`, 20, 20);
|
|
|
|
doc.setFontSize(12);
|
|
doc.text(`Client: ${devis.client_email}`, 20, 40);
|
|
|
|
doc.text(`Date: ${new Date(devis.date).toLocaleDateString("fr-FR")}`, 140, 40);
|
|
doc.text(`Date d'échéance: ${new Date(devis.due_date).toLocaleDateString("fr-FR")}`, 140, 48);
|
|
|
|
// --- Amounts ---
|
|
const startYAmounts = 70;
|
|
doc.line(20, startYAmounts - 5, 190, startYAmounts - 5); // Separator line
|
|
doc.text(`Sous-total HT: ${devis.subtotal.toFixed(2)} €`, 140, startYAmounts);
|
|
doc.text(`TVA: ${devis.tax.toFixed(2)} €`, 140, startYAmounts + 8);
|
|
doc.setFontSize(14);
|
|
doc.setFont("helvetica", "bold");
|
|
doc.text(`Total TTC: ${devis.total.toFixed(2)} €`, 140, startYAmounts + 18);
|
|
doc.setFont("helvetica", "normal"); // Reset font
|
|
doc.line(20, startYAmounts + 25, 190, startYAmounts + 25); // Separator line
|
|
|
|
// --- Notes & Terms (if available) ---
|
|
let currentY = startYAmounts + 40;
|
|
if (devis.notes) {
|
|
doc.setFontSize(12);
|
|
doc.setFont("helvetica", "bold");
|
|
doc.text("Notes:", 20, currentY);
|
|
doc.setFont("helvetica", "normal");
|
|
// Use splitTextToSize for wrapping long text
|
|
const notesLines = doc.splitTextToSize(devis.notes, 170); // 170 is max width
|
|
doc.text(notesLines, 20, currentY + 8);
|
|
currentY += notesLines.length * 5 + 15; // Adjust Y position based on lines
|
|
}
|
|
|
|
if (devis.terms) {
|
|
doc.setFontSize(12);
|
|
doc.setFont("helvetica", "bold");
|
|
doc.text("Conditions:", 20, currentY);
|
|
doc.setFont("helvetica", "normal");
|
|
const termsLines = doc.splitTextToSize(devis.terms, 170);
|
|
doc.text(termsLines, 20, currentY + 8);
|
|
}
|
|
|
|
// --- Save the PDF ---
|
|
doc.save(`devis_${devis.number}.pdf`);
|
|
};
|
|
|
|
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;
|
|
|
|
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(`Tablo: ${event.tablo_name}\n${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") => {
|
|
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);
|
|
};
|
|
|
|
// ICS Import functionality
|
|
export interface ParsedICSEvent {
|
|
title: string;
|
|
description?: string;
|
|
start_date: string;
|
|
start_time: string;
|
|
end_time?: string;
|
|
}
|
|
|
|
export const parseICSFile = (icsContent: string): ParsedICSEvent[] => {
|
|
const events: ParsedICSEvent[] = [];
|
|
const lines = icsContent.split(/\r?\n/);
|
|
|
|
let currentEvent: Partial<ParsedICSEvent> | null = null;
|
|
|
|
const unescapeICSText = (text: string) => {
|
|
return text
|
|
.replace(/\\n/g, "\n")
|
|
.replace(/\\,/g, ",")
|
|
.replace(/\\;/g, ";")
|
|
.replace(/\\\\/g, "\\");
|
|
};
|
|
|
|
const parseDateTime = (dateTimeStr: string) => {
|
|
// Handle both YYYYMMDDTHHMMSSZ and YYYYMMDDTHHMMSS formats
|
|
const cleanDate = dateTimeStr.replace(/[TZ]/g, "");
|
|
|
|
if (cleanDate.length >= 8) {
|
|
const year = cleanDate.substring(0, 4);
|
|
const month = cleanDate.substring(4, 6);
|
|
const day = cleanDate.substring(6, 8);
|
|
const date = `${year}-${month}-${day}`;
|
|
|
|
if (cleanDate.length >= 14) {
|
|
const hour = cleanDate.substring(8, 10);
|
|
const minute = cleanDate.substring(10, 12);
|
|
const second = cleanDate.substring(12, 14) || "00";
|
|
const time = `${hour}:${minute}:${second}`;
|
|
return { date, time };
|
|
} else {
|
|
return { date, time: "00:00:00" };
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let line = lines[i].trim();
|
|
|
|
// Handle line folding (lines starting with space or tab)
|
|
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) {
|
|
line += lines[i + 1].substring(1);
|
|
i++;
|
|
}
|
|
|
|
if (line === "BEGIN:VEVENT") {
|
|
currentEvent = {};
|
|
} else if (line === "END:VEVENT" && currentEvent) {
|
|
if (currentEvent.title && currentEvent.start_date && currentEvent.start_time) {
|
|
events.push(currentEvent as ParsedICSEvent);
|
|
}
|
|
currentEvent = null;
|
|
} else if (currentEvent && line.includes(":")) {
|
|
const colonIndex = line.indexOf(":");
|
|
const property = line.substring(0, colonIndex);
|
|
const value = line.substring(colonIndex + 1);
|
|
|
|
// Handle properties that might have parameters (e.g., DTSTART;TZID=...)
|
|
const [propName] = property.split(";");
|
|
|
|
switch (propName) {
|
|
case "SUMMARY":
|
|
currentEvent.title = unescapeICSText(value);
|
|
break;
|
|
case "DESCRIPTION":
|
|
currentEvent.description = unescapeICSText(value);
|
|
break;
|
|
case "DTSTART": {
|
|
const startDateTime = parseDateTime(value);
|
|
if (startDateTime) {
|
|
currentEvent.start_date = startDateTime.date;
|
|
currentEvent.start_time = startDateTime.time;
|
|
}
|
|
break;
|
|
}
|
|
case "DTEND": {
|
|
const endDateTime = parseDateTime(value);
|
|
if (endDateTime) {
|
|
// Only use end time if it's on the same date
|
|
const startDate = currentEvent.start_date;
|
|
if (startDate === endDateTime.date) {
|
|
currentEvent.end_time = endDateTime.time;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return events;
|
|
};
|
|
|
|
export const getTextColorFromTabloColor = (tabloColor: string): string => {
|
|
// Map of tablo background colors to their corresponding text colors
|
|
const colorMap: Record<string, string> = {
|
|
// Blue variants
|
|
"bg-blue-100": "text-blue-800",
|
|
"bg-blue-200": "text-blue-900",
|
|
"bg-blue-300": "text-blue-900",
|
|
"bg-blue-400": "text-white",
|
|
"bg-blue-500": "text-white",
|
|
"bg-blue-600": "text-white",
|
|
"bg-blue-700": "text-white",
|
|
"bg-blue-800": "text-white",
|
|
"bg-blue-900": "text-white",
|
|
|
|
// Green variants
|
|
"bg-green-100": "text-green-800",
|
|
"bg-green-200": "text-green-900",
|
|
"bg-green-300": "text-green-900",
|
|
"bg-green-400": "text-white",
|
|
"bg-green-500": "text-white",
|
|
"bg-green-600": "text-white",
|
|
"bg-green-700": "text-white",
|
|
"bg-green-800": "text-white",
|
|
"bg-green-900": "text-white",
|
|
|
|
// Red variants
|
|
"bg-red-100": "text-red-800",
|
|
"bg-red-200": "text-red-900",
|
|
"bg-red-300": "text-red-900",
|
|
"bg-red-400": "text-white",
|
|
"bg-red-500": "text-white",
|
|
"bg-red-600": "text-white",
|
|
"bg-red-700": "text-white",
|
|
"bg-red-800": "text-white",
|
|
"bg-red-900": "text-white",
|
|
|
|
// Yellow variants
|
|
"bg-yellow-100": "text-yellow-800",
|
|
"bg-yellow-200": "text-yellow-900",
|
|
"bg-yellow-300": "text-yellow-900",
|
|
"bg-yellow-400": "text-yellow-900",
|
|
"bg-yellow-500": "text-white",
|
|
"bg-yellow-600": "text-white",
|
|
"bg-yellow-700": "text-white",
|
|
"bg-yellow-800": "text-white",
|
|
"bg-yellow-900": "text-white",
|
|
|
|
// Purple variants
|
|
"bg-purple-100": "text-purple-800",
|
|
"bg-purple-200": "text-purple-900",
|
|
"bg-purple-300": "text-purple-900",
|
|
"bg-purple-400": "text-white",
|
|
"bg-purple-500": "text-white",
|
|
"bg-purple-600": "text-white",
|
|
"bg-purple-700": "text-white",
|
|
"bg-purple-800": "text-white",
|
|
"bg-purple-900": "text-white",
|
|
|
|
// Pink variants
|
|
"bg-pink-100": "text-pink-800",
|
|
"bg-pink-200": "text-pink-900",
|
|
"bg-pink-300": "text-pink-900",
|
|
"bg-pink-400": "text-white",
|
|
"bg-pink-500": "text-white",
|
|
"bg-pink-600": "text-white",
|
|
"bg-pink-700": "text-white",
|
|
"bg-pink-800": "text-white",
|
|
"bg-pink-900": "text-white",
|
|
|
|
// Indigo variants
|
|
"bg-indigo-100": "text-indigo-800",
|
|
"bg-indigo-200": "text-indigo-900",
|
|
"bg-indigo-300": "text-indigo-900",
|
|
"bg-indigo-400": "text-white",
|
|
"bg-indigo-500": "text-white",
|
|
"bg-indigo-600": "text-white",
|
|
"bg-indigo-700": "text-white",
|
|
"bg-indigo-800": "text-white",
|
|
"bg-indigo-900": "text-white",
|
|
|
|
// Gray variants
|
|
"bg-gray-100": "text-gray-800",
|
|
"bg-gray-200": "text-gray-900",
|
|
"bg-gray-300": "text-gray-900",
|
|
"bg-gray-400": "text-white",
|
|
"bg-gray-500": "text-white",
|
|
"bg-gray-600": "text-white",
|
|
"bg-gray-700": "text-white",
|
|
"bg-gray-800": "text-white",
|
|
"bg-gray-900": "text-white",
|
|
};
|
|
|
|
return colorMap[tabloColor] || "text-gray-900";
|
|
};
|