Merge branch 'develop'
This commit is contained in:
commit
9c330810b8
11 changed files with 601 additions and 35 deletions
|
|
@ -8,11 +8,9 @@ import type { SupabaseClient, User } from "@supabase/supabase-js";
|
|||
import type { Transporter } from "nodemailer";
|
||||
import { generateToken } from "./token.js";
|
||||
import { config } from "./config.js";
|
||||
import type { Database, Tables } from "./database.types.js";
|
||||
import type { Tables } from "./database.types.js";
|
||||
import type { StreamChat } from "stream-chat";
|
||||
|
||||
type Tablo = Database["public"]["Tables"]["tablos"];
|
||||
type TabloInsert = Tablo["Insert"];
|
||||
import type { TabloInsert, EventInsertInTablo } from "./types.js";
|
||||
|
||||
export const tabloRouter = new Hono<{
|
||||
Variables: {
|
||||
|
|
@ -27,18 +25,23 @@ tabloRouter.use(authMiddleware);
|
|||
tabloRouter.use(emailMiddleware);
|
||||
tabloRouter.use(streamChatMiddleware);
|
||||
|
||||
type PostTablo = Omit<TabloInsert, "owner_id"> & {
|
||||
events?: EventInsertInTablo[];
|
||||
};
|
||||
|
||||
tabloRouter.post("/create", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const data = await c.req.json();
|
||||
|
||||
const tablo = data as Omit<TabloInsert, "owner_id">;
|
||||
const typedPayload = data as PostTablo;
|
||||
|
||||
const { data: insertedTablo, error } = await supabase
|
||||
.from("tablos")
|
||||
.insert({
|
||||
...tablo,
|
||||
...typedPayload,
|
||||
owner_id: user.id,
|
||||
events: undefined,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
|
@ -52,12 +55,21 @@ tabloRouter.post("/create", async (c) => {
|
|||
const streamServerClient = c.get("streamServerClient");
|
||||
const channel = streamServerClient.channel("messaging", tabloData.id, {
|
||||
// @ts-ignore
|
||||
name: tablo.name,
|
||||
name: tabloData.name,
|
||||
created_by_id: user.id,
|
||||
members: [user.id],
|
||||
});
|
||||
await channel.create();
|
||||
|
||||
if (typedPayload.events) {
|
||||
const eventsToInsert = typedPayload.events.map((event) => ({
|
||||
...event,
|
||||
tablo_id: tabloData.id,
|
||||
created_by: user.id,
|
||||
}));
|
||||
|
||||
await supabase.from("events").insert(eventsToInsert);
|
||||
}
|
||||
return c.json({ message: "Tablo created successfully" });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,41 @@
|
|||
type User = {
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
import type {
|
||||
Database,
|
||||
Tables,
|
||||
TablesInsert,
|
||||
TablesUpdate,
|
||||
} from "./database.types.js";
|
||||
|
||||
export type Tablo = Database["public"]["Tables"]["tablos"];
|
||||
export type TabloInsert = Tablo["Insert"];
|
||||
|
||||
export type Event = RemoveNullFromObject<
|
||||
Tables<"events">,
|
||||
"created_at" | "end_time"
|
||||
>;
|
||||
export type EventInsertInTablo = Omit<TablesInsert<"events">, "tablo_id">;
|
||||
export type EventUpdate = TablesUpdate<"events">;
|
||||
|
||||
export type EventAndTablo = RemoveNullFromObject<
|
||||
Tables<"events_and_tablos">,
|
||||
| "event_id"
|
||||
| "tablo_id"
|
||||
| "tablo_name"
|
||||
| "tablo_color"
|
||||
| "tablo_status"
|
||||
| "start_time"
|
||||
| "end_time"
|
||||
| "title"
|
||||
| "start_date"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Utility type to remove null from a type
|
||||
*/
|
||||
export type RemoveNull<T> = T extends null ? never : T;
|
||||
|
||||
/**
|
||||
* Utility type to remove null from all properties of an object type
|
||||
*/
|
||||
export type RemoveNullFromObject<T, K extends keyof T = keyof T> = {
|
||||
[L in keyof T]: L extends K ? RemoveNull<T[L]> : T[L];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Event, EventInsert } from "@ui/types/events.types";
|
||||
import { useState } from "react";
|
||||
import { useTablosList } from "@ui/hooks/tablos";
|
||||
import { useCreateEvent } from "@ui/hooks/events";
|
||||
import { useCreateEvents } from "@ui/hooks/events";
|
||||
import { useUser } from "@ui/providers/UserStoreProvider";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -23,7 +23,7 @@ export const CreateEventModal = () => {
|
|||
const date = new Date(searchParams.get("date") || "");
|
||||
|
||||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
const createEvent = useCreateEvent();
|
||||
const createEvents = useCreateEvents();
|
||||
const timeOptions = useTimePicker({ intervalInMinute: 15 });
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -286,7 +286,7 @@ export const CreateEventModal = () => {
|
|||
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm hover:shadow-md"
|
||||
onClick={() => {
|
||||
const eventName = createdEvent?.title.trim() || "(Sans titre)";
|
||||
createEvent(
|
||||
createEvents(
|
||||
{ ...createdEvent, title: eventName },
|
||||
{ onSuccess: () => onClose() }
|
||||
);
|
||||
|
|
|
|||
356
ui/src/components/ImportICSModal.tsx
Normal file
356
ui/src/components/ImportICSModal.tsx
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { useTablosList, useCreateTablo } from "@ui/hooks/tablos";
|
||||
import { useCreateEvents } from "@ui/hooks/events";
|
||||
import { useUser } from "@ui/providers/UserStoreProvider";
|
||||
import {
|
||||
Select,
|
||||
SelectButton,
|
||||
SelectPopover,
|
||||
SelectListBox,
|
||||
SelectListItem,
|
||||
} from "@ui/ui-library/select";
|
||||
import { parseICSFile, ParsedICSEvent } from "@ui/utils/helpers";
|
||||
import { EventInsert } from "@ui/types/events.types";
|
||||
import { CreateTablo } from "@ui/types/tablos.types";
|
||||
import { toast } from "@ui/ui-library/toast/toast-queue";
|
||||
|
||||
interface ImportICSModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ImportICSModal = ({ onClose }: ImportICSModalProps) => {
|
||||
const user = useUser();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [parsedEvents, setParsedEvents] = useState<ParsedICSEvent[]>([]);
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>("");
|
||||
const [newTabloName, setNewTabloName] = useState("");
|
||||
const [createNewTablo, setCreateNewTablo] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
const createTabloMutation = useCreateTablo();
|
||||
const createEvents = useCreateEvents();
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && file.name.endsWith(".ics")) {
|
||||
setSelectedFile(file);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
try {
|
||||
const events = parseICSFile(content);
|
||||
setParsedEvents(events);
|
||||
if (events.length === 0) {
|
||||
toast.add(
|
||||
{
|
||||
title: "Aucun événement trouvé",
|
||||
description:
|
||||
"Le fichier ICS ne contient aucun événement valide",
|
||||
type: "warning",
|
||||
},
|
||||
{ timeout: 4000 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing ICS file:", error);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur de lecture",
|
||||
description: "Impossible de lire le fichier ICS",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 4000 }
|
||||
);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
toast.add(
|
||||
{
|
||||
title: "Format invalide",
|
||||
description: "Veuillez sélectionner un fichier .ics",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 4000 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (parsedEvents.length === 0) {
|
||||
toast.add(
|
||||
{
|
||||
title: "Aucun événement",
|
||||
description: "Aucun événement à importer",
|
||||
type: "warning",
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createNewTablo && !selectedTabloId) {
|
||||
toast.add(
|
||||
{
|
||||
title: "Tablo requis",
|
||||
description: "Veuillez sélectionner un tablo ou créer un nouveau",
|
||||
type: "warning",
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (createNewTablo && !newTabloName.trim()) {
|
||||
toast.add(
|
||||
{
|
||||
title: "Nom du tablo requis",
|
||||
description: "Veuillez saisir un nom pour le nouveau tablo",
|
||||
type: "warning",
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
|
||||
try {
|
||||
const targetTabloId = selectedTabloId;
|
||||
|
||||
const eventsToInsert = parsedEvents.map((event) => {
|
||||
const eventData: EventInsert = {
|
||||
title: event.title,
|
||||
description: event.description || "",
|
||||
start_date: event.start_date,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time || event.start_time,
|
||||
tablo_id: targetTabloId,
|
||||
created_by: user.id,
|
||||
};
|
||||
return eventData;
|
||||
});
|
||||
|
||||
if (createNewTablo) {
|
||||
const newTabloData: CreateTablo = {
|
||||
name: newTabloName.trim(),
|
||||
color: "bg-blue-500",
|
||||
image: null,
|
||||
status: "todo",
|
||||
events: eventsToInsert,
|
||||
};
|
||||
|
||||
await createTabloMutation.mutateAsync(newTabloData);
|
||||
|
||||
toast.add(
|
||||
{
|
||||
title: "Import réussi",
|
||||
description: `${parsedEvents.length} événement(s) importé(s) avec succès`,
|
||||
type: "success",
|
||||
},
|
||||
{ timeout: 4000 }
|
||||
);
|
||||
} else {
|
||||
await createEvents(eventsToInsert);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error importing events:", error);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur d'import",
|
||||
description: "Une erreur est survenue lors de l'import",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 4000 }
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">Importer un fichier ICS</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-200 transition-colors"
|
||||
aria-label="Fermer le modal"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-green-100 text-sm">
|
||||
Importez vos événements depuis un fichier calendrier
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* File Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Fichier ICS *
|
||||
</label>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept=".ics"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
|
||||
>
|
||||
Choisir un fichier
|
||||
</button>
|
||||
{selectedFile && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{parsedEvents.length > 0 && (
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{parsedEvents.length} événement(s) trouvé(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tablo Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Destination
|
||||
</label>
|
||||
|
||||
{/* Create new tablo option */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="createNewTablo"
|
||||
checked={createNewTablo}
|
||||
onChange={(e) => {
|
||||
setCreateNewTablo(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
setSelectedTabloId("");
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<label
|
||||
htmlFor="createNewTablo"
|
||||
className="text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Créer un nouveau tablo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{createNewTablo ? (
|
||||
<input
|
||||
type="text"
|
||||
value={newTabloName}
|
||||
onChange={(e) => setNewTabloName(e.target.value)}
|
||||
placeholder="Nom du nouveau tablo"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
placeholder="Sélectionner un tablo existant"
|
||||
selectedKey={selectedTabloId}
|
||||
onSelectionChange={(key) => setSelectedTabloId(key as string)}
|
||||
className="w-full"
|
||||
isDisabled={tablosLoading}
|
||||
>
|
||||
<SelectButton className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-800 dark:text-white text-left" />
|
||||
<SelectPopover>
|
||||
<SelectListBox>
|
||||
{tablos?.map((tablo) => (
|
||||
<SelectListItem key={tablo.id} id={tablo.id}>
|
||||
{tablo.name}
|
||||
</SelectListItem>
|
||||
))}
|
||||
</SelectListBox>
|
||||
</SelectPopover>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedEvents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Aperçu des événements
|
||||
</label>
|
||||
<div className="max-h-32 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-800">
|
||||
{parsedEvents.slice(0, 5).map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs text-gray-600 dark:text-gray-400 py-1"
|
||||
>
|
||||
<span className="font-medium">{event.title}</span>
|
||||
<span className="ml-2 opacity-75">
|
||||
{event.start_date} {event.start_time.substring(0, 5)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{parsedEvents.length > 5 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500 pt-1">
|
||||
... et {parsedEvents.length - 5} autre(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-6 py-4 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
onClick={onClose}
|
||||
disabled={isImporting}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm hover:shadow-md"
|
||||
onClick={handleImport}
|
||||
disabled={
|
||||
isImporting ||
|
||||
parsedEvents.length === 0 ||
|
||||
(!createNewTablo && !selectedTabloId) ||
|
||||
(createNewTablo && !newTabloName.trim())
|
||||
}
|
||||
>
|
||||
{isImporting
|
||||
? "Import en cours..."
|
||||
: `Importer ${parsedEvents.length} événement(s)`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,15 +11,16 @@ import {
|
|||
|
||||
// Fetch events for a specific tablo
|
||||
export const useEventsByTablo = (tabloId: string | null) => {
|
||||
const key = tabloId ? ["events", tabloId] : ["events", "all"];
|
||||
return useQuery({
|
||||
queryKey: ["events", tabloId],
|
||||
queryKey: key,
|
||||
queryFn: async () => {
|
||||
if (!tabloId) {
|
||||
const { data, error } = await supabase
|
||||
.from("events_and_tablos")
|
||||
.select("*")
|
||||
.order("start_date", { ascending: true })
|
||||
.order("start_time", { ascending: true });
|
||||
.order("start_date", { ascending: false })
|
||||
.order("start_time", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data as EventAndTablo[];
|
||||
|
|
@ -28,8 +29,8 @@ export const useEventsByTablo = (tabloId: string | null) => {
|
|||
.from("events_and_tablos")
|
||||
.select("*")
|
||||
.eq("tablo_id", tabloId)
|
||||
.order("start_date", { ascending: true })
|
||||
.order("start_time", { ascending: true });
|
||||
.order("start_date", { ascending: false })
|
||||
.order("start_time", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data as EventAndTablo[];
|
||||
|
|
@ -83,30 +84,41 @@ export const useEvent = (eventId: string) => {
|
|||
};
|
||||
|
||||
// Create new event
|
||||
export const useCreateEvent = () => {
|
||||
export const useCreateEvents = () => {
|
||||
const user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (event: EventInsert) => {
|
||||
mutationFn: async (events: EventInsert | EventInsert[]) => {
|
||||
const { data, error } = await supabase
|
||||
.from("events")
|
||||
.insert({
|
||||
...event,
|
||||
created_by: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
.insert(
|
||||
Array.isArray(events)
|
||||
? events.map((e) => ({
|
||||
...e,
|
||||
created_by: user.id,
|
||||
}))
|
||||
: {
|
||||
...events,
|
||||
created_by: user.id,
|
||||
}
|
||||
)
|
||||
.select();
|
||||
|
||||
if (error) throw error;
|
||||
return data as Event;
|
||||
return data as Event[];
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["events"] });
|
||||
const eventsCount = Array.isArray(data) ? data.length : 1;
|
||||
const pluralizeText =
|
||||
eventsCount === 1
|
||||
? "L'événement a été créé"
|
||||
: "Les événements ont été créés";
|
||||
toast.add(
|
||||
{
|
||||
title: "Événement créé",
|
||||
description: "L'événement a été créé avec succès",
|
||||
title: `${eventsCount} événement(s) créé(s)`,
|
||||
description: `${pluralizeText} avec succès`,
|
||||
type: "success",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import { api } from "@ui/lib/api";
|
|||
import { toast } from "@ui/ui-library/toast/toast-queue";
|
||||
import { RemoveNullFromObject } from "@ui/types/removeNull";
|
||||
import { useUser } from "@ui/providers/UserStoreProvider";
|
||||
import { CreateTablo } from "@ui/types/tablos.types";
|
||||
|
||||
type Tablo = Database["public"]["Tables"]["tablos"];
|
||||
|
||||
type TabloInsert = Tablo["Insert"];
|
||||
type TabloUpdate = Tablo["Update"];
|
||||
|
||||
type UserTablo = RemoveNullFromObject<
|
||||
|
|
@ -74,9 +74,7 @@ export const useCreateTablo = () => {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
tablo: Pick<TabloInsert, "name" | "color" | "image" | "status">
|
||||
) => {
|
||||
mutationFn: async (tablo: CreateTablo) => {
|
||||
const { data } = await api.post("/api/v1/tablos/create", tablo, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
|
|
@ -127,6 +125,8 @@ export const useUpdateTablo = () => {
|
|||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tablos"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tablos", id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["events", "all"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["events", id] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "@ui/ui-library/select";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import { generateICSFromEvents, downloadICSFile } from "@ui/utils/helpers";
|
||||
import { ImportICSModal } from "@ui/components/ImportICSModal";
|
||||
|
||||
type ViewType = "month" | "week" | "day";
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export const PlanningPage = () => {
|
|||
const [selectedTabloId, setSelectedTabloId] = useState<string>(
|
||||
tablo_id || "all"
|
||||
);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
|
||||
// Fetch tablos
|
||||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
|
|
@ -692,6 +694,26 @@ export const PlanningPage = () => {
|
|||
>
|
||||
+ Créer un événement
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsImportModalOpen(true)}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-sm mt-2 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
|
||||
/>
|
||||
</svg>
|
||||
<span>Importer un planning</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mini Calendar */}
|
||||
|
|
@ -884,6 +906,10 @@ export const PlanningPage = () => {
|
|||
)} */}
|
||||
|
||||
<Outlet />
|
||||
|
||||
{isImportModalOpen && (
|
||||
<ImportICSModal onClose={() => setIsImportModalOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type Event = RemoveNullFromObject<
|
|||
export type EventInsert = TablesInsert<"events">;
|
||||
export type EventUpdate = TablesUpdate<"events">;
|
||||
|
||||
export type EventInsertInTablo = Omit<EventInsert, "tablo_id">;
|
||||
export type EventAndTablo = RemoveNullFromObject<
|
||||
Tables<"events_and_tablos">,
|
||||
| "event_id"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Database } from "@ui/types/database.types";
|
||||
import { RemoveNullFromObject } from "@ui/types/removeNull";
|
||||
import { EventInsertInTablo } from "@ui/types/events.types";
|
||||
|
||||
export type UserTablo = RemoveNullFromObject<
|
||||
Database["public"]["Views"]["user_tablos"]["Row"],
|
||||
|
|
@ -18,3 +19,10 @@ export type Tablo = Database["public"]["Tables"]["tablos"];
|
|||
|
||||
export type TabloInsert = Tablo["Insert"];
|
||||
export type TabloUpdate = Tablo["Update"];
|
||||
|
||||
export type CreateTablo = Pick<
|
||||
TabloInsert,
|
||||
"name" | "color" | "image" | "status"
|
||||
> & {
|
||||
events?: EventInsertInTablo[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -135,4 +135,12 @@
|
|||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,3 +166,110 @@ export const downloadICSFile = (
|
|||
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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue